From 81eef23fce01d68d9d02929b78620bd0a269e108 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 12:05:55 -0400 Subject: [PATCH 01/19] docs: add host context adapter design spec Spec for decoupling signet-node from reth's ExExContext by introducing a HostNotifier trait in signet-node-types, with the reth implementation isolated in a new signet-host-reth crate. Co-Authored-By: Claude Opus 4.6 --- .../2026-03-13-host-context-adapter-design.md | 393 ++++++++++++++++++ 1 file changed, 393 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-13-host-context-adapter-design.md diff --git a/docs/superpowers/specs/2026-03-13-host-context-adapter-design.md b/docs/superpowers/specs/2026-03-13-host-context-adapter-design.md new file mode 100644 index 0000000..652b617 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-host-context-adapter-design.md @@ -0,0 +1,393 @@ +# Host Context Adapter: Decoupling signet-node from reth ExEx + +**Date:** 2026-03-13 +**Status:** Approved + +## Goal + +Replace `signet-node`'s direct dependency on reth's `ExExContext` with a +trait abstraction, enabling alternative host backends (e.g. RPC-backed) without +reth in the dependency graph of `signet-node`. + +## Scope + +This design covers **`signet-node` only**. Other crates with reth dependencies +(`signet-blobber`, `signet-node-config`) are out of scope. + +The abstraction is a single `HostNotifier` trait covering: +1. **Notifications stream** — committed, reverted, and reorged chain segments, + with safe/finalized block numbers bundled into each notification +2. **Backfill** — head positioning and batch size thresholds +3. **Event feedback** — notifying the host of processed height + +Out of scope for the trait: transaction pool access, RPC config inheritance, +host block lookups (eliminated — see design notes). + +## Architecture + +Three crates are modified or created: + +``` +signet-extract signet-node-types signet-host-reth +(Extractable extended) (HostNotifier trait) (reth ExEx impl) + \ | / + \ | / + v v v + signet-node + (generic over HostNotifier) +``` + +### `signet-extract` (modified) + +The `Extractable` trait gains provided methods for chain segment metadata. +These are used during revert handling and notification instrumentation. + +```rust +pub trait Extractable: Debug + Sync { + type Block: BlockHeader + HasTxns + Debug + Sync; + type Receipt: TxReceipt + Debug + Sync; + + fn blocks_and_receipts( + &self, + ) -> impl Iterator)>; + + /// Block number of the first block in the segment. + /// + /// # Panics + /// + /// Panics if the chain segment is empty. + fn first_number(&self) -> u64 { + self.blocks_and_receipts() + .next() + .expect("chain segment is empty") + .0 + .number() + } + + /// Block number of the tip (last block) in the segment. + /// + /// # Panics + /// + /// Panics if the chain segment is empty. + fn tip_number(&self) -> u64 { + self.blocks_and_receipts() + .last() + .expect("chain segment is empty") + .0 + .number() + } + + /// Number of blocks in the segment. + fn len(&self) -> usize { + self.blocks_and_receipts().count() + } + + /// Whether the segment is empty. + fn is_empty(&self) -> bool { + self.blocks_and_receipts().next().is_none() + } +} +``` + +Backends (e.g. reth's chain wrapper) can override these for O(1) performance. +The default implementations iterate `blocks_and_receipts()`. + +### `signet-node-types` (new crate) + +Minimal crate defining the host abstraction. No reth dependencies. + +**Dependencies:** `alloy`, `signet-extract`, `std`/`core`. + +**Contents:** + +#### `HostNotifier` trait + +```rust +pub trait HostNotifier { + /// A chain segment — contiguous blocks with receipts. + type Chain: Extractable; + + /// The error type for fallible operations. + type Error: std::error::Error + Send + Sync + 'static; + + /// Yield the next notification. `None` signals host shutdown. + fn next_notification( + &mut self, + ) -> impl Future, Self::Error>>> + Send; + + /// Set the head position, requesting backfill from this block number. + /// The backend resolves the block number to a block hash internally. + fn set_head(&mut self, block_number: u64); + + /// Configure backfill batch size limits. + fn set_backfill_thresholds(&mut self, max_blocks: Option); + + /// Signal that processing is complete up to this host block number. + /// The backend resolves the block number to a block hash internally. + fn send_finished_height(&self, block_number: u64) -> Result<(), Self::Error>; +} +``` + +**Design notes — elimination of host block lookups:** + +The previous design had a `HostReader` trait for point-in-time lookups +(`sealed_header`, `safe_block_number`, `finalized_block_number`). These +lookups served two purposes: + +1. **Per-notification:** Reading safe/finalized block numbers for tag updates, + and resolving a header hash for `FinishedHeight` feedback. +2. **Startup:** Resolving a block number to a `NumHash` for `set_head`. + +Both are eliminated: + +- **Per-notification lookups** are replaced by bundling `safe_block_number` + and `finalized_block_number` into `HostNotification`. The header hash for + `FinishedHeight` feedback is resolved by the backend — `send_finished_height` + takes a `u64` block number, and the backend looks up the hash internally. +- **Startup lookups** are eliminated by having `set_head` take a `u64` block + number instead of a `NumHash`. The backend resolves the hash internally. + +This means `signet-node` never performs host chain lookups. All block data +comes from notifications, and all hash resolution is the backend's +responsibility. This structurally prevents inconsistent reads during block +processing — there are no lookup methods to misuse. + +#### `HostNotification` types + +```rust +pub struct HostNotification { + /// The chain event (commit, revert, or reorg). + pub kind: HostNotificationKind, + + /// The host chain "safe" block number at the time of this notification. + pub safe_block_number: Option, + + /// The host chain "finalized" block number at the time of this notification. + pub finalized_block_number: Option, +} + +pub enum HostNotificationKind { + ChainCommitted { new: Arc }, + ChainReverted { old: Arc }, + ChainReorged { old: Arc, new: Arc }, +} +``` + +Accessor semantics on `HostNotificationKind`: +- `committed_chain()` returns `Some(&new)` for `ChainCommitted` and + `ChainReorged`, `None` for `ChainReverted`. +- `reverted_chain()` returns `Some(&old)` for `ChainReverted` and + `ChainReorged`, `None` for `ChainCommitted`. + +This matches the existing `ExExNotification` behavior and ensures the +"reverts run first" pattern in `on_notification` works correctly. + +### `signet-host-reth` (new crate) + +Implements `HostNotifier` for reth's ExEx. Owns all reth dependencies. + +**Dependencies:** `signet-node-types`, `signet-blobber` (for chain shim), +`reth`, `reth-exex`, `reth-node-api`, `reth-stages-types`, `alloy`. + +**Contents:** + +#### `RethHostNotifier` + +```rust +pub struct RethHostNotifier { + notifications: ExExNotificationsStream, + provider: Host::Provider, + events: UnboundedSender, +} +``` + +- `HostNotifier::Chain` → an owning wrapper around `reth::providers::Chain` + that implements `Extractable` (by providing a borrowed + `ExtractableChainShim` internally), overriding `first_number`, + `tip_number`, `len`, and `is_empty` for O(1) performance +- `HostNotifier::Error` → reth error type or `eyre::Report` +- `next_notification` wraps the reth notification stream, reads + `safe_block_number` and `finalized_block_number` from the provider, and + bundles them into `HostNotification` +- `set_head` resolves the block number to a `NumHash` via the provider's + `BlockReader`, then calls `set_with_head` on the notifications stream +- `send_finished_height` resolves the block number to a `NumHash` via the + provider's `HeaderProvider`, then sends `ExExEvent::FinishedHeight` + +The provider is held by the notifier (not exposed to `signet-node`) so that +hash resolution stays internal to the backend. + +#### Moved from `signet-node` + +- `RethAliasOracleFactory` / `RethAliasOracle` — uses `StateProviderFactory` + to query host chain state for alias decisions +- The owning chain wrapper with `Extractable` impl (O(1) overrides) + +#### Convenience constructor + +Takes an `ExExContext` and returns: +1. A `RethHostNotifier` (the adapter) +2. A `ServeConfig` (plain RPC config extracted from `ExExContext::config.rpc`) +3. A `StorageRpcConfig` (gas oracle settings extracted from reth config) +4. The transaction pool handle (`Host::Pool`) for blob cacher construction +5. A chain ID / chain name for tracing + +This cleanly splits the `ExExContext` into the parts that flow through the +trait and the parts that are consumed at construction time. + +### `signet-node` (modified) + +Drops all reth dependencies. Replaces them with `signet-node-types`. + +#### `SignetNode` + +```rust +pub struct SignetNode +where + N: HostNotifier, + H: HotKv, +{ + notifier: N, + config: Arc, + storage: Arc>, + chain_name: String, + // ... rest unchanged +} +``` + +The notification loop becomes: + +```rust +async fn start_inner(&mut self) -> eyre::Result<()> { + // Startup: tell the backend where we are + self.notifier.set_head(last_rollup_block_host_height); + + // Notification loop + while let Some(notification) = self.notifier.next_notification().await { + let notification = notification?; + let changed = self.on_notification(¬ification).await?; + + if changed { + // safe/finalized come from the notification itself + self.update_block_tags( + notification.safe_block_number, + notification.finalized_block_number, + )?; + } + } +} +``` + +No host block lookups anywhere in `signet-node`. All block data comes from +the notification's chain, and all hash resolution is the backend's job. + +**Handling `ctx.pool()` (blob cacher):** The `BlobFetcher` construction that +currently calls `ctx.pool().clone()` moves out of `SignetNode::new_unsafe`. The +builder accepts a pre-built `CacheHandle` instead. The caller (assembly crate) +constructs the `BlobFetcher` using the pool returned from the +`signet-host-reth` convenience constructor, then passes the `CacheHandle` to +the builder. + +**Handling chain ID for tracing:** The `#[instrument]` attribute on `start()` +currently reads `self.host.config.chain.chain()`. This is replaced by a +`chain_name: String` field on `SignetNode`, set during construction from the +chain ID returned by the `signet-host-reth` convenience constructor. + +**Handling `set_exex_head`:** The current method resolves block numbers to +hashes via provider lookups, constructs an `ExExHead`, and logs it. After the +change, `set_head(u64)` delegates hash resolution to the backend. Logging +uses the block number directly. The `ExExHead` type is no longer needed in +`signet-node`. + +**`num_hash_slow()` elimination:** All `num_hash_slow()` calls disappear. +Hash resolution is fully internal to the backend. + +Method translations: +- `self.host.notifications.next().await` → + `self.notifier.next_notification().await` +- `self.host.provider().block_by_number(n)` → eliminated (backend resolves) +- `self.host.provider().sealed_header(n)` → eliminated (backend resolves) +- `self.host.provider().safe_block_number()` → + `notification.safe_block_number` (bundled) +- `self.host.provider().finalized_block_number()` → + `notification.finalized_block_number` (bundled) +- `self.host.notifications.set_with_head(head)` → + `self.notifier.set_head(block_number)` +- `self.host.notifications.set_backfill_thresholds(t)` → + `self.notifier.set_backfill_thresholds(max_blocks)` +- `self.host.events.send(ExExEvent::FinishedHeight(h))` → + `self.notifier.send_finished_height(block_number)` + +#### `SignetNodeBuilder` + +```rust +pub fn with_notifier(self, n: N) -> SignetNodeBuilder +``` + +New builder methods: +- `with_notifier(N)` — accepts the notification adapter +- `with_blob_cacher(CacheHandle)` — accepts pre-built blob cacher +- `with_serve_config(ServeConfig)` — accepts plain RPC config +- `with_chain_name(String)` — accepts chain name for tracing + +The `build()` variant that creates a default `RethAliasOracleFactory` from the +provider moves to `signet-host-reth` (extension trait or convenience function). + +#### Metrics + +`record_notification_received` and `record_notification_processed` change from +`&ExExNotification` to `&HostNotification`. Same logic, different types. + +#### RPC config + +The `merge_rpc_configs` method is removed from `signet-node-config`. The +`signet-host-reth` convenience constructor extracts `RpcServerArgs` from the +`ExExContext` and converts them to a `ServeConfig` and `StorageRpcConfig`, +which are passed to the builder. The `rpc_config_from_args` and +`serve_config_from_args` helpers (currently in `signet-node/src/rpc.rs`) move +to `signet-host-reth` since they operate on reth's `RpcServerArgs` type. +The `signet-node` RPC module receives pre-built config values only. + +## What does NOT change + +- `signet-blobber` retains its reth dependencies (transaction pool, `Chain`) +- `signet-node-tests` continues to use `reth-exex-test-utils` — tests construct + a `RethHostNotifier` from the test ExEx context +- All other workspace crates are unaffected +- The binary assembly crate pulls in both `signet-node` and `signet-host-reth` + +## Testing strategy + +- **`signet-node` unit tests:** A mock `HostNotifier` backed by channels/vecs + for notifications. No lookup mocks needed — there are no lookup methods. + This replaces the need for `reth-exex-test-utils` in unit tests. +- **`signet-node-tests` integration tests:** Continue using + `reth-exex-test-utils` via `RethHostNotifier` from `signet-host-reth`. + +## Implementation order + +1. Extend `Extractable` in `signet-extract` with `first_number`, `tip_number`, + `len`, `is_empty` as provided methods +2. Create `signet-node-types` with `HostNotifier`, `HostNotification`, + `HostNotificationKind` +3. Create `signet-host-reth` with `RethHostNotifier` and moved reth-specific + code (alias oracle, chain wrapper with O(1) overrides) +4. Refactor `signet-node` to depend on `signet-node-types` instead of reth, + update `SignetNode`/`SignetNodeBuilder`, move blob cacher construction to + caller +5. Update `signet-node-tests` to construct `RethHostNotifier` +6. Remove reth deps from `signet-node/Cargo.toml` + +## Design decisions + +| Decision | Rationale | +|----------|-----------| +| Single `HostNotifier` trait | All host interaction flows through one interface; no separate reader needed | +| Lookups bundled into notification | Safe/finalized numbers travel with the notification; hash resolution is the backend's job. Structurally prevents inconsistent reads during processing | +| `set_head` and `send_finished_height` take `u64` | Backend resolves block hashes internally; signet-node never queries the host chain | +| Associated types for `Chain` | Flexibility for backends with different chain representations | +| Chain metadata merged into `Extractable` | `first_number`, `tip_number`, `len` are derivable from `blocks_and_receipts`; eliminates a separate `HostChain` trait. Panics on empty match current reth behavior | +| Reth impl in dedicated crate | Keeps all reth deps isolated; signet-node is reth-free | +| RPC config extracted at call site | Simpler than threading config through the trait; eliminates reth dep from signet-node-config | +| `CacheHandle` passed into builder | Moves pool dependency out of signet-node; caller constructs blob cacher | +| Chain name as construction param | Avoids threading host config type through the trait for a single tracing field | From 06c5e92c6e87a2ed79e5fee5a3a224b11a33081a Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 12:15:48 -0400 Subject: [PATCH 02/19] docs: add host context adapter implementation plan 14-task plan covering: Extractable extension in SDK, signet-node-types crate, signet-host-reth crate, signet-node refactoring, and test updates. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-13-host-context-adapter.md | 1210 +++++++++++++++++ 1 file changed, 1210 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-13-host-context-adapter.md diff --git a/docs/superpowers/plans/2026-03-13-host-context-adapter.md b/docs/superpowers/plans/2026-03-13-host-context-adapter.md new file mode 100644 index 0000000..e9bca78 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-host-context-adapter.md @@ -0,0 +1,1210 @@ +# Host Context Adapter Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Decouple signet-node from reth's ExExContext by introducing a HostNotifier trait, isolating all reth-specific code in a dedicated signet-host-reth crate. + +**Architecture:** A single `HostNotifier` trait (in `signet-node-types`) abstracts over the host chain. Notifications bundle safe/finalized block numbers. Hash resolution is the backend's responsibility. The reth implementation lives in `signet-host-reth`. `signet-node` becomes reth-free. + +**Tech Stack:** Rust, alloy, reth (isolated in signet-host-reth only), tokio, tracing + +**Spec:** `docs/superpowers/specs/2026-03-13-host-context-adapter-design.md` + +--- + +## Chunk 1: Extend Extractable and Create signet-node-types + +### Task 1: Extend `Extractable` in the SDK + +The `Extractable` trait lives in the external SDK repo at `../sdk/crates/extract/src/trait.rs`. We add provided methods for chain segment metadata (`first_number`, `tip_number`, `len`, `is_empty`) and patch the components workspace to use the local copy. + +**Files:** +- Modify: `../sdk/crates/extract/src/trait.rs` +- Modify: `Cargo.toml` (workspace root — uncomment path patches) + +- [ ] **Step 1: Add provided methods to `Extractable`** + +In `../sdk/crates/extract/src/trait.rs`, add after the `blocks_and_receipts` method: + +```rust +/// Block number of the first block in the segment. +/// +/// # Panics +/// +/// Panics if the chain segment is empty. +fn first_number(&self) -> u64 { + self.blocks_and_receipts() + .next() + .expect("chain segment is empty") + .0 + .number() +} + +/// Block number of the tip (last block) in the segment. +/// +/// # Panics +/// +/// Panics if the chain segment is empty. +fn tip_number(&self) -> u64 { + self.blocks_and_receipts() + .last() + .expect("chain segment is empty") + .0 + .number() +} + +/// Number of blocks in the segment. +fn len(&self) -> usize { + self.blocks_and_receipts().count() +} + +/// Whether the segment is empty. +fn is_empty(&self) -> bool { + self.blocks_and_receipts().next().is_none() +} +``` + +Note: `number()` comes from the `BlockHeader` bound on `Self::Block`. + +- [ ] **Step 2: Add `BlockHeader` import** + +The trait file needs `use alloy::consensus::BlockHeader;` for the `.number()` call in default impls. Add this import at the top of `../sdk/crates/extract/src/trait.rs`. + +- [ ] **Step 3: Verify SDK builds** + +Run: `cargo clippy -p signet-extract --all-features --all-targets` (from `../sdk/`) +Expected: Clean pass. The new methods are provided with defaults, so no existing impls break. + +- [ ] **Step 4: Uncomment path patches in components workspace** + +In `/Users/james/devel/init4/components/Cargo.toml`, uncomment the SDK path overrides (lines 122–130): + +```toml +signet-bundle = { path = "../sdk/crates/bundle"} +signet-constants = { path = "../sdk/crates/constants"} +signet-evm = { path = "../sdk/crates/evm"} +signet-extract = { path = "../sdk/crates/extract"} +signet-journal = { path = "../sdk/crates/journal"} +signet-test-utils = { path = "../sdk/crates/test-utils"} +signet-tx-cache = { path = "../sdk/crates/tx-cache"} +signet-types = { path = "../sdk/crates/types"} +signet-zenith = { path = "../sdk/crates/zenith"} +``` + +- [ ] **Step 5: Verify components workspace builds with patched SDK** + +Run: `cargo clippy -p signet-node --all-features --all-targets` +Expected: Clean pass. Nothing in components uses the new methods yet. + +- [ ] **Step 6: Commit SDK change** + +```bash +cd ../sdk +git add crates/extract/src/trait.rs +git commit -m "feat: add chain segment metadata methods to Extractable" +``` + +- [ ] **Step 7: Commit components workspace patch** + +```bash +cd /Users/james/devel/init4/components +git add Cargo.toml +git commit -m "chore: enable local SDK path overrides for Extractable changes" +``` + +--- + +### Task 2: Create `signet-node-types` crate + +New crate with the `HostNotifier` trait, `HostNotification`, and `HostNotificationKind`. No reth dependencies. + +**Files:** +- Create: `crates/node-types/Cargo.toml` +- Create: `crates/node-types/src/lib.rs` +- Create: `crates/node-types/src/notifier.rs` +- Create: `crates/node-types/src/notification.rs` +- Create: `crates/node-types/README.md` +- Modify: `Cargo.toml` (workspace root — add to dependencies) + +- [ ] **Step 1: Create crate directory and README** + +```bash +mkdir -p crates/node-types/src +``` + +Write `crates/node-types/README.md`: +```markdown +# signet-node-types + +Trait abstractions for the signet node's host chain interface. +``` + +- [ ] **Step 2: Write `Cargo.toml`** + +Create `crates/node-types/Cargo.toml`: + +```toml +[package] +name = "signet-node-types" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +signet-extract.workspace = true + +alloy.workspace = true +``` + +- [ ] **Step 3: Add workspace dependency** + +In the root `Cargo.toml`, add to `[workspace.dependencies]` near the other local crates: + +```toml +signet-node-types = { version = "0.16.0-rc.7", path = "crates/node-types" } +``` + +- [ ] **Step 4: Write `HostNotificationKind` and `HostNotification`** + +Create `crates/node-types/src/notification.rs`: + +```rust +use signet_extract::Extractable; +use std::sync::Arc; + +/// A notification from the host chain, bundling a chain event with +/// point-in-time block tag data. +#[derive(Debug, Clone)] +pub struct HostNotification { + /// The chain event (commit, revert, or reorg). + pub kind: HostNotificationKind, + /// The host chain "safe" block number at the time of this notification. + pub safe_block_number: Option, + /// The host chain "finalized" block number at the time of this + /// notification. + pub finalized_block_number: Option, +} + +/// The kind of chain event in a [`HostNotification`]. +#[derive(Debug, Clone)] +pub enum HostNotificationKind { + /// A new chain segment was committed. + ChainCommitted { + /// The newly committed chain segment. + new: Arc, + }, + /// A chain segment was reverted. + ChainReverted { + /// The reverted chain segment. + old: Arc, + }, + /// A chain reorg occurred: one segment was reverted and replaced by + /// another. + ChainReorged { + /// The reverted chain segment. + old: Arc, + /// The newly committed chain segment. + new: Arc, + }, +} + +impl HostNotificationKind { + /// Returns the committed chain, if any. + /// + /// Returns `Some` for [`ChainCommitted`] and [`ChainReorged`], `None` + /// for [`ChainReverted`]. + /// + /// [`ChainCommitted`]: HostNotificationKind::ChainCommitted + /// [`ChainReorged`]: HostNotificationKind::ChainReorged + /// [`ChainReverted`]: HostNotificationKind::ChainReverted + pub fn committed_chain(&self) -> Option<&Arc> { + match self { + Self::ChainCommitted { new } | Self::ChainReorged { new, .. } => Some(new), + Self::ChainReverted { .. } => None, + } + } + + /// Returns the reverted chain, if any. + /// + /// Returns `Some` for [`ChainReverted`] and [`ChainReorged`], `None` + /// for [`ChainCommitted`]. + /// + /// [`ChainReverted`]: HostNotificationKind::ChainReverted + /// [`ChainReorged`]: HostNotificationKind::ChainReorged + /// [`ChainCommitted`]: HostNotificationKind::ChainCommitted + pub fn reverted_chain(&self) -> Option<&Arc> { + match self { + Self::ChainReverted { old } | Self::ChainReorged { old, .. } => Some(old), + Self::ChainCommitted { .. } => None, + } + } +} +``` + +- [ ] **Step 5: Write `HostNotifier` trait** + +Create `crates/node-types/src/notifier.rs`: + +```rust +use crate::HostNotification; +use core::future::Future; +use signet_extract::Extractable; + +/// Abstraction over a host chain notification source. +/// +/// Drives the signet node's main loop: yielding chain events, controlling +/// backfill, and sending feedback. All block data comes from notifications; +/// the backend handles hash resolution internally. +/// +/// # Implementors +/// +/// - `signet-host-reth`: wraps reth's `ExExContext` +pub trait HostNotifier { + /// A chain segment — contiguous blocks with receipts. + type Chain: Extractable; + + /// The error type for fallible operations. + type Error: core::error::Error + Send + Sync + 'static; + + /// Yield the next notification. `None` signals host shutdown. + fn next_notification( + &mut self, + ) -> impl Future, Self::Error>>> + Send; + + /// Set the head position, requesting backfill from this block number. + /// The backend resolves the block number to a block hash internally. + fn set_head(&mut self, block_number: u64); + + /// Configure backfill batch size limits. + fn set_backfill_thresholds(&mut self, max_blocks: Option); + + /// Signal that processing is complete up to this host block number. + /// The backend resolves the block number to a block hash internally. + fn send_finished_height(&self, block_number: u64) -> Result<(), Self::Error>; +} +``` + +- [ ] **Step 6: Write `lib.rs`** + +Create `crates/node-types/src/lib.rs`: + +```rust +#![doc = include_str!("../README.md")] +#![warn( + missing_copy_implementations, + missing_debug_implementations, + missing_docs, + unreachable_pub, + clippy::missing_const_for_fn, + rustdoc::all +)] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![deny(unused_must_use, rust_2018_idioms)] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] + +mod notification; +pub use notification::{HostNotification, HostNotificationKind}; + +mod notifier; +pub use notifier::HostNotifier; +``` + +- [ ] **Step 7: Lint the new crate** + +Run: `cargo clippy -p signet-node-types --all-features --all-targets` +Expected: Clean pass. + +Run: `cargo +nightly fmt` +Expected: Clean. + +- [ ] **Step 8: Commit** + +```bash +git add crates/node-types/ Cargo.toml +git commit -m "feat: add signet-node-types crate with HostNotifier trait" +``` + +--- + +## Chunk 2: Create signet-host-reth + +### Task 3: Create `signet-host-reth` crate scaffold + +New crate that wraps reth's ExEx types behind the `HostNotifier` trait. + +**Files:** +- Create: `crates/host-reth/Cargo.toml` +- Create: `crates/host-reth/src/lib.rs` +- Create: `crates/host-reth/src/chain.rs` +- Create: `crates/host-reth/src/notifier.rs` +- Create: `crates/host-reth/src/alias.rs` +- Create: `crates/host-reth/src/config.rs` +- Create: `crates/host-reth/README.md` +- Modify: `Cargo.toml` (workspace root — add dependency) + +- [ ] **Step 1: Create crate directory and README** + +```bash +mkdir -p crates/host-reth/src +``` + +Write `crates/host-reth/README.md`: +```markdown +# signet-host-reth + +Reth ExEx implementation of the `HostNotifier` trait for signet-node. +``` + +- [ ] **Step 2: Write `Cargo.toml`** + +Create `crates/host-reth/Cargo.toml`: + +```toml +[package] +name = "signet-host-reth" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +signet-node-types.workspace = true +signet-blobber.workspace = true +signet-extract.workspace = true +signet-rpc.workspace = true +signet-block-processor.workspace = true + +alloy.workspace = true +reth.workspace = true +reth-exex.workspace = true +reth-node-api.workspace = true +reth-stages-types.workspace = true + +eyre.workspace = true +futures-util.workspace = true +tracing.workspace = true +``` + +- [ ] **Step 3: Add workspace dependency** + +In root `Cargo.toml`, add to `[workspace.dependencies]`: + +```toml +signet-host-reth = { version = "0.16.0-rc.7", path = "crates/host-reth" } +``` + +- [ ] **Step 4: Commit scaffold** + +```bash +git add crates/host-reth/Cargo.toml crates/host-reth/README.md Cargo.toml +git commit -m "chore: scaffold signet-host-reth crate" +``` + +--- + +### Task 4: Implement the reth chain wrapper + +An owning wrapper around `reth::providers::Chain` that implements `Extractable` with O(1) overrides for `first_number`, `tip_number`, `len`. + +**Files:** +- Create: `crates/host-reth/src/chain.rs` + +**Reference:** `crates/blobber/src/shim.rs` for the existing `ExtractableChainShim` pattern. + +- [ ] **Step 1: Write the chain wrapper** + +Create `crates/host-reth/src/chain.rs`: + +```rust +use alloy::consensus::BlockHeader; +use reth::primitives::EthPrimitives; +use reth::providers::Chain; +use signet_blobber::{ExtractableChainShim, RecoveredBlockShim}; +use signet_extract::Extractable; +use std::sync::Arc; + +/// An owning wrapper around reth's [`Chain`] that implements [`Extractable`] +/// with O(1) metadata accessors. +#[derive(Debug)] +pub struct RethChain { + inner: Arc>, +} + +impl RethChain { + /// Wrap a reth chain. + pub fn new(chain: Arc>) -> Self { + Self { inner: chain } + } +} + +impl Extractable for RethChain { + type Block = RecoveredBlockShim; + type Receipt = reth::primitives::Receipt; + + fn blocks_and_receipts( + &self, + ) -> impl Iterator)> { + ExtractableChainShim::new(&self.inner).blocks_and_receipts() + } + + fn first_number(&self) -> u64 { + self.inner.first().number() + } + + fn tip_number(&self) -> u64 { + self.inner.tip().number() + } + + fn len(&self) -> usize { + self.inner.len() + } + + fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} +``` + +Note: The `blocks_and_receipts` impl delegates to `ExtractableChainShim` which already handles the `repr(transparent)` transmute. The lifetime on the returned iterator borrows from `self.inner` via the shim. If the borrow checker complains because `ExtractableChainShim` holds a temporary, the implementation may need to inline the shim logic. Verify during implementation. + +- [ ] **Step 2: Verify it compiles** + +Add `mod chain;` to a temporary `lib.rs` and run: +`cargo clippy -p signet-host-reth --all-features --all-targets` + +- [ ] **Step 3: Commit** + +```bash +git add crates/host-reth/src/chain.rs +git commit -m "feat(host-reth): add RethChain wrapper with O(1) Extractable overrides" +``` + +--- + +### Task 5: Move alias oracle to signet-host-reth + +Move `RethAliasOracle` and `RethAliasOracleFactory` from `signet-node` to `signet-host-reth`. + +**Files:** +- Create: `crates/host-reth/src/alias.rs` +- Reference: `crates/node/src/alias.rs` (copy and adapt) + +- [ ] **Step 1: Copy alias.rs to host-reth** + +Copy `/Users/james/devel/init4/components/crates/node/src/alias.rs` to `crates/host-reth/src/alias.rs`. The contents are identical — no changes needed to the code itself. + +- [ ] **Step 2: Verify it compiles** + +Add `mod alias; pub use alias::{RethAliasOracle, RethAliasOracleFactory};` to lib.rs. +Run: `cargo clippy -p signet-host-reth --all-features --all-targets` + +- [ ] **Step 3: Commit** + +```bash +git add crates/host-reth/src/alias.rs +git commit -m "feat(host-reth): move RethAliasOracle from signet-node" +``` + +--- + +### Task 6: Move RPC config helpers to signet-host-reth + +Move `rpc_config_from_args` and `serve_config_from_args` from `signet-node/src/rpc.rs`. + +**Files:** +- Create: `crates/host-reth/src/config.rs` +- Reference: `crates/node/src/rpc.rs:49-72` + +- [ ] **Step 1: Write config.rs** + +Create `crates/host-reth/src/config.rs` with the two functions from `crates/node/src/rpc.rs`: + +```rust +use reth::args::RpcServerArgs; +use signet_rpc::{ServeConfig, StorageRpcConfig}; +use std::net::SocketAddr; + +/// Extract [`StorageRpcConfig`] values from reth's host RPC settings. +pub fn rpc_config_from_args(args: &RpcServerArgs) -> StorageRpcConfig { + let gpo = &args.gas_price_oracle; + StorageRpcConfig::builder() + .rpc_gas_cap(args.rpc_gas_cap) + .max_tracing_requests(args.rpc_max_tracing_requests) + .gas_oracle_block_count(gpo.blocks as u64) + .gas_oracle_percentile(gpo.percentile as f64) + .ignore_price(Some(gpo.ignore_price as u128)) + .max_price(Some(gpo.max_price as u128)) + .build() +} + +/// Convert reth [`RpcServerArgs`] into a reth-free [`ServeConfig`]. +pub fn serve_config_from_args(args: &RpcServerArgs) -> ServeConfig { + let http = if args.http { + vec![SocketAddr::from((args.http_addr, args.http_port))] + } else { + vec![] + }; + let ws = if args.ws { + vec![SocketAddr::from((args.ws_addr, args.ws_port))] + } else { + vec![] + }; + let ipc = if !args.ipcdisable { + Some(args.ipcpath.clone()) + } else { + None + }; + ServeConfig { + http, + http_cors: args.http_corsdomain.clone(), + ws, + ws_cors: args.ws_allowed_origins.clone(), + ipc, + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Add `mod config; pub use config::{rpc_config_from_args, serve_config_from_args};` to lib.rs. +Run: `cargo clippy -p signet-host-reth --all-features --all-targets` + +- [ ] **Step 3: Commit** + +```bash +git add crates/host-reth/src/config.rs +git commit -m "feat(host-reth): move RPC config helpers from signet-node" +``` + +--- + +### Task 7: Implement `RethHostNotifier` and convenience constructor + +The core adapter: wraps `ExExNotificationsStream`, provider, and events sender behind `HostNotifier`. + +**Files:** +- Create: `crates/host-reth/src/notifier.rs` +- Modify: `crates/host-reth/src/lib.rs` (final version) + +- [ ] **Step 1: Write `RethHostNotifier`** + +Create `crates/host-reth/src/notifier.rs`: + +```rust +use crate::{RethChain, config::{rpc_config_from_args, serve_config_from_args}}; +use alloy::consensus::BlockHeader; +use alloy::eips::NumHash; +use futures_util::StreamExt; +use reth::{ + chainspec::EthChainSpec, + primitives::EthPrimitives, + providers::{BlockIdReader, BlockReader, HeaderProvider}, +}; +use reth_exex::{ExExContext, ExExEvent, ExExNotificationsStream}; +use reth_node_api::{FullNodeComponents, FullNodeTypes, NodeTypes}; +use reth_stages_types::ExecutionStageThresholds; +use signet_node_types::{HostNotification, HostNotificationKind, HostNotifier}; +use signet_rpc::{ServeConfig, StorageRpcConfig}; +use std::sync::Arc; +use tracing::debug; + +/// Reth ExEx implementation of [`HostNotifier`]. +/// +/// Wraps reth's notification stream, provider, and event sender. All hash +/// resolution happens internally — consumers only work with block numbers. +pub struct RethHostNotifier { + notifications: ExExNotificationsStream, + provider: Host::Provider, + events: tokio::sync::mpsc::UnboundedSender, +} + +impl core::fmt::Debug for RethHostNotifier { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RethHostNotifier").finish_non_exhaustive() + } +} + +/// The output of [`decompose_exex_context`]. +pub struct DecomposedContext { + /// The host notifier adapter. + pub notifier: RethHostNotifier, + /// Plain RPC serve config. + pub serve_config: ServeConfig, + /// Plain RPC storage config. + pub rpc_config: StorageRpcConfig, + /// The transaction pool, for blob cacher construction. + pub pool: Host::Pool, + /// The chain name, for tracing. + pub chain_name: String, +} + +impl core::fmt::Debug for DecomposedContext { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("DecomposedContext") + .field("chain_name", &self.chain_name) + .finish_non_exhaustive() + } +} + +/// Decompose a reth [`ExExContext`] into a [`RethHostNotifier`] and +/// associated configuration values. +/// +/// This splits the ExEx context into: +/// - A [`RethHostNotifier`] (implements [`HostNotifier`]) +/// - A [`ServeConfig`] (plain RPC server config) +/// - A [`StorageRpcConfig`] (gas oracle settings) +/// - The transaction pool handle +/// - A chain name for tracing +pub fn decompose_exex_context(ctx: ExExContext) -> DecomposedContext +where + Host: FullNodeComponents, + Host::Types: NodeTypes, +{ + let chain_name = ctx.config.chain.chain().to_string(); + let serve_config = serve_config_from_args(&ctx.config.rpc); + let rpc_config = rpc_config_from_args(&ctx.config.rpc); + let pool = ctx.pool().clone(); + let provider = ctx.provider().clone(); + + let notifier = RethHostNotifier { + notifications: ctx.notifications, + provider, + events: ctx.events, + }; + + DecomposedContext { notifier, serve_config, rpc_config, pool, chain_name } +} + +impl HostNotifier for RethHostNotifier +where + Host: FullNodeComponents, + Host::Types: NodeTypes, +{ + type Chain = RethChain; + type Error = eyre::Report; + + async fn next_notification( + &mut self, + ) -> Option, Self::Error>> { + let notification = self.notifications.next().await?; + let notification = match notification { + Ok(n) => n, + Err(e) => return Some(Err(e.into())), + }; + + // Read safe/finalized from the provider at notification time. + let safe_block_number = self + .provider + .safe_block_number() + .ok() + .flatten(); + let finalized_block_number = self + .provider + .finalized_block_number() + .ok() + .flatten(); + + let kind = match notification { + reth_exex::ExExNotification::ChainCommitted { new } => { + HostNotificationKind::ChainCommitted { + new: Arc::new(RethChain::new(new)), + } + } + reth_exex::ExExNotification::ChainReverted { old } => { + HostNotificationKind::ChainReverted { + old: Arc::new(RethChain::new(old)), + } + } + reth_exex::ExExNotification::ChainReorged { old, new } => { + HostNotificationKind::ChainReorged { + old: Arc::new(RethChain::new(old)), + new: Arc::new(RethChain::new(new)), + } + } + }; + + Some(Ok(HostNotification { kind, safe_block_number, finalized_block_number })) + } + + fn set_head(&mut self, block_number: u64) { + let block = self + .provider + .block_by_number(block_number) + .expect("failed to look up block for set_head"); + + let head = match block { + Some(b) => b.num_hash_slow(), + None => { + debug!(block_number, "block not found for set_head, falling back to genesis"); + let genesis = self + .provider + .block_by_number(0) + .expect("failed to look up genesis block") + .expect("genesis block missing"); + genesis.num_hash_slow() + } + }; + + let exex_head = reth_exex::ExExHead { block: head }; + self.notifications.set_with_head(exex_head); + } + + fn set_backfill_thresholds(&mut self, max_blocks: Option) { + if let Some(max_blocks) = max_blocks { + self.notifications + .set_backfill_thresholds(ExecutionStageThresholds { + max_blocks: Some(max_blocks), + ..Default::default() + }); + debug!(max_blocks, "configured backfill thresholds"); + } + } + + fn send_finished_height(&self, block_number: u64) -> Result<(), Self::Error> { + let header = self + .provider + .sealed_header(block_number)? + .ok_or_else(|| { + eyre::eyre!( + "no host header for finished height {block_number}" + ) + })?; + + let hash = header.hash(); + self.events + .send(ExExEvent::FinishedHeight(NumHash { + number: block_number, + hash, + }))?; + Ok(()) + } +} +``` + +- [ ] **Step 2: Write final `lib.rs`** + +Create `crates/host-reth/src/lib.rs`: + +```rust +#![doc = include_str!("../README.md")] +#![warn( + missing_copy_implementations, + missing_debug_implementations, + missing_docs, + unreachable_pub, + clippy::missing_const_for_fn, + rustdoc::all +)] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![deny(unused_must_use, rust_2018_idioms)] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] + +mod alias; +pub use alias::{RethAliasOracle, RethAliasOracleFactory}; + +mod chain; +pub use chain::RethChain; + +mod config; +pub use config::{rpc_config_from_args, serve_config_from_args}; + +mod notifier; +pub use notifier::{DecomposedContext, RethHostNotifier, decompose_exex_context}; +``` + +- [ ] **Step 3: Lint** + +Run: `cargo clippy -p signet-host-reth --all-features --all-targets` +Expected: Clean pass. + +Run: `cargo +nightly fmt` + +- [ ] **Step 4: Commit** + +```bash +git add crates/host-reth/src/ +git commit -m "feat(host-reth): implement RethHostNotifier and decompose_exex_context" +``` + +--- + +## Chunk 3: Refactor signet-node + +### Task 8: Add signet-node-types dependency to signet-node + +Add the new dependency alongside reth (temporarily). Reth deps are removed in Task 11 after all code is updated. + +**Files:** +- Modify: `crates/node/Cargo.toml` + +- [ ] **Step 1: Add signet-node-types to Cargo.toml** + +In `crates/node/Cargo.toml`, add: +```toml +signet-node-types.workspace = true +``` + +Keep the reth deps for now — they'll be removed after the code changes. + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo clippy -p signet-node --all-features --all-targets` +Expected: Clean pass (nothing changed yet). + +- [ ] **Step 3: Commit** + +```bash +git add crates/node/Cargo.toml +git commit -m "chore(node): add signet-node-types dependency" +``` + +--- + +### Task 9: Refactor `SignetNode` struct and notification loop + +Replace `ExExContext` with `N: HostNotifier`. Update `start`, `start_inner`, `on_notification`, and related methods. + +**Files:** +- Modify: `crates/node/src/node.rs` +- Modify: `crates/node/src/lib.rs` + +- [ ] **Step 1: Rewrite `node.rs`** + +Replace the entire file. Key changes: +- `SignetNode` → `SignetNode` where `N: HostNotifier` +- Remove `Host: FullNodeComponents` bounds +- `host: ExExContext` → `notifier: N` +- Add `chain_name: String` field +- Remove `type PrimitivesOf`, `type ExExNotification`, `type Chain` aliases +- `new_unsafe` takes `notifier: N` instead of `ctx: ExExContext`, plus `chain_name: String` and `blob_cacher: CacheHandle` +- `start` instrument field: `host = ?self.host.config.chain.chain()` → `host = %self.chain_name` +- `start` error handler (`.inspect_err`): replace `self.set_exex_head(h)` with `self.notifier.set_head(host_height)` — the error recovery path just logs the block number now +- `start_inner`: replace `set_exex_head` with `self.notifier.set_head(host_height)` and `self.notifier.set_backfill_thresholds(self.config.backfill_max_blocks())` +- Notification loop: `self.host.notifications.next().await` → `self.notifier.next_notification().await` +- `on_notification` takes `&HostNotification` by reference (currently takes `ExExNotification` by value). The `Arc` inside notifications makes borrowing cheap. +- `notification.reverted_chain()` → `notification.kind.reverted_chain()` +- `notification.committed_chain()` → `notification.kind.committed_chain()` +- `process_committed_chain` takes `&Arc` where `N::Chain: Extractable`. Remove the `ExtractableChainShim::new(chain)` call — the chain already implements `Extractable` directly. Use the chain as-is with `Extractor::extract_signet`. +- `on_host_revert` takes `&Arc`, uses `chain.first_number()` and `chain.tip_number()` +- **Decompose `update_status`:** Split into two parts: + - `update_status_channel(ru_height)` — just updates `self.status.send_modify(...)`. Called from `on_notification` as before. + - `update_block_tags(safe_block_number, finalized_block_number)` — takes the bundled values from the notification. Called from `start_inner` AFTER `on_notification` returns. + This ensures block tags are updated exactly once per notification, using the values bundled in the notification. +- `on_notification` returns `(bool, u64)` — whether anything changed AND the current rollup height (for the status channel update). Or: `on_notification` still calls `self.update_status_channel()` internally for the status/height update, and returns `bool` for whether `update_block_tags` should run. +- `load_safe_block_heights` takes `safe_block_number: Option` param +- `load_finalized_block_heights` takes `finalized_block_number: Option` param +- `update_highest_processed_height` calls `self.notifier.send_finished_height(adjusted_height)` +- Remove `set_exex_head` entirely +- Remove `set_backfill_thresholds` method (logic moves to `start_inner` directly) +- Remove `signet_blobber::ExtractableChainShim` from imports (no longer needed) + +The `start_inner` notification loop becomes: +```rust +while let Some(notification) = self.notifier.next_notification().await { + let notification = + notification.wrap_err("error in host notifications stream")?; + let changed = self.on_notification(¬ification).await?; + if changed { + // safe/finalized come from the notification, not from lookups + self.update_block_tags( + notification.safe_block_number, + notification.finalized_block_number, + )?; + } +} +``` + +- [ ] **Step 2: Update `lib.rs`** + +Remove the `RethAliasOracle` and `RethAliasOracleFactory` re-exports (they moved to `signet-host-reth`): + +```rust +// Remove: +mod alias; +pub use alias::{RethAliasOracle, RethAliasOracleFactory}; +``` + +- [ ] **Step 3: Attempt compilation** + +Run: `cargo clippy -p signet-node --all-features --all-targets 2>&1 | head -40` +Expected: Errors in builder.rs, rpc.rs, metrics.rs — addressed in next tasks. + +- [ ] **Step 4: Commit** + +```bash +git add crates/node/src/node.rs crates/node/src/lib.rs +git commit -m "refactor(node): replace ExExContext with HostNotifier trait" +``` + +--- + +### Task 10: Refactor `SignetNodeBuilder` + +Update the builder to accept `N: HostNotifier` instead of `ExExContext`. + +**Files:** +- Modify: `crates/node/src/builder.rs` + +- [ ] **Step 1: Rewrite builder.rs** + +Key changes: +- Remove all `reth` / `reth_exex` / `reth_node_api` imports +- `SignetNodeBuilder` → `SignetNodeBuilder` +- `with_ctx` → `with_notifier`, takes `N: HostNotifier` +- Add `with_chain_name(String)`, `with_blob_cacher(CacheHandle)`, `with_serve_config(ServeConfig)` methods +- Add `chain_name: Option`, `blob_cacher: Option`, `serve_config: Option` fields +- Remove the `build()` impl that creates `RethAliasOracleFactory` from provider (this moves to signet-host-reth or is done by the caller) +- The remaining `build()` requires an explicit `AliasOracleFactory` +- `prebuild` no longer needs `ExExContext` — it only does storage genesis checks + +- [ ] **Step 2: Attempt compilation** + +Run: `cargo clippy -p signet-node --all-features --all-targets 2>&1 | head -40` +Expected: Errors in rpc.rs and metrics.rs still. + +- [ ] **Step 3: Commit** + +```bash +git add crates/node/src/builder.rs +git commit -m "refactor(node): update SignetNodeBuilder for HostNotifier" +``` + +--- + +### Task 11: Refactor metrics and RPC modules + +Update metrics to use `HostNotification` and RPC to use pre-built configs. + +**Files:** +- Modify: `crates/node/src/metrics.rs` +- Modify: `crates/node/src/rpc.rs` + +- [ ] **Step 1: Update metrics.rs** + +Replace reth notification types with `HostNotificationKind`: + +```rust +use signet_extract::Extractable; +use signet_node_types::HostNotification; +``` + +Change `record_notification_received` and `record_notification_processed` to take `&HostNotification` where `C: Extractable`: + +```rust +pub(crate) fn record_notification_received( + notification: &HostNotification, +) { + inc_notifications_received(); + if notification.kind.reverted_chain().is_some() { + inc_reorgs_received(); + } +} + +pub(crate) fn record_notification_processed( + notification: &HostNotification, +) { + inc_notifications_processed(); + if notification.kind.reverted_chain().is_some() { + inc_reorgs_processed(); + } +} +``` + +Remove the `reth` and `reth_exex` imports. + +- [ ] **Step 2: Update rpc.rs** + +Remove all reth imports. The RPC module now receives pre-built `ServeConfig` and `StorageRpcConfig`. The `SignetNode` struct holds these as fields (set via builder). + +The `launch_rpc` method no longer calls `self.config.merge_rpc_configs(&self.host)`. Instead it uses `self.serve_config` and `self.rpc_config` directly. + +Remove `rpc_config_from_args` and `serve_config_from_args` (moved to `signet-host-reth`). + +- [ ] **Step 3: Remove alias.rs from signet-node** + +Delete `crates/node/src/alias.rs` — it now lives in `signet-host-reth`. + +- [ ] **Step 4: Lint** + +Run: `cargo clippy -p signet-node --all-features --all-targets` +Expected: Clean pass. signet-node should have zero reth imports. + +Run: `cargo +nightly fmt` + +- [ ] **Step 5: Verify no reth imports remain in source** + +Run: `grep -r "reth" crates/node/src/` +Expected: No matches (except possibly comments). + +- [ ] **Step 6: Remove reth deps from Cargo.toml** + +In `crates/node/Cargo.toml`, remove: +```toml +reth.workspace = true +reth-exex.workspace = true +reth-node-api.workspace = true +reth-stages-types.workspace = true +``` + +- [ ] **Step 7: Final lint with reth deps removed** + +Run: `cargo clippy -p signet-node --all-features --all-targets` +Expected: Clean pass. + +- [ ] **Step 8: Commit** + +```bash +git add crates/node/ +git commit -m "refactor(node): remove all reth dependencies from signet-node" +``` + +--- + +### Task 12: Remove ExEx dependency from signet-node-config + +Remove the `merge_rpc_configs` method and its reth ExEx/node-api deps. Note: `reth` and `reth-chainspec` deps remain — they are used by `core.rs` for `ChainSpec`, `StaticFileProvider`, etc. Full reth removal from node-config is out of scope. + +**Files:** +- Delete: `crates/node-config/src/rpc.rs` +- Modify: `crates/node-config/src/lib.rs` (remove `mod rpc;`) +- Modify: `crates/node-config/Cargo.toml` + +- [ ] **Step 1: Delete `crates/node-config/src/rpc.rs`** + +The file only contains `modify_args` and `merge_rpc_configs` — both depend on `ExExContext` and `RpcServerArgs`. Delete the entire file. + +- [ ] **Step 2: Remove `mod rpc;` from lib.rs** + +In `crates/node-config/src/lib.rs`, remove the `mod rpc;` line. + +- [ ] **Step 3: Remove ExEx deps from Cargo.toml** + +In `crates/node-config/Cargo.toml`, remove only: +```toml +reth-exex.workspace = true +reth-node-api.workspace = true +``` + +Keep `reth.workspace = true` and `reth-chainspec.workspace = true` — they are still used by `core.rs`. + +- [ ] **Step 4: Lint** + +Run: `cargo clippy -p signet-node-config --all-features --all-targets` +Run: `cargo clippy -p signet-node-config --no-default-features --all-targets` +Expected: Clean pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/node-config/ +git commit -m "refactor(node-config): remove ExEx dependency" +``` + +--- + +## Chunk 4: Update tests + +### Task 13: Update signet-node-tests + +The test harness needs to construct `RethHostNotifier` from the test ExEx context and use the new builder API. + +**Files:** +- Modify: `crates/node-tests/Cargo.toml` +- Modify: `crates/node-tests/src/context.rs` +- Modify: `crates/node-tests/src/lib.rs` + +- [ ] **Step 1: Add signet-host-reth dependency** + +In `crates/node-tests/Cargo.toml`, add: +```toml +signet-host-reth.workspace = true +``` + +- [ ] **Step 2: Update context.rs** + +In `crates/node-tests/src/context.rs`, the `SignetTestContext::new()` method currently: +1. Creates a test ExEx context via `reth_exex_test_utils::test_exex_context()` +2. Passes it to `SignetNodeBuilder::new(config).with_ctx(ctx)...build()` + +Update to: +1. Create test ExEx context (same as before) +2. Call `decompose_exex_context(ctx)` to get `DecomposedContext` +3. Build blob cacher from the pool +4. Pass `notifier`, `blob_cacher`, `serve_config`, `chain_name` to the new builder API + +The `send_notification` method currently sends via `self.handle.notifications_tx`. This should still work — the `RethHostNotifier` wraps the same notification stream that the test handle writes to. + +- [ ] **Step 3: Update lib.rs re-exports if needed** + +Ensure `signet-host-reth` types are available if needed by tests. + +- [ ] **Step 4: Run tests** + +Run: `cargo t -p signet-node-tests` +Expected: All tests pass. + +- [ ] **Step 5: Lint** + +Run: `cargo clippy -p signet-node-tests --all-features --all-targets` + +- [ ] **Step 6: Commit** + +```bash +git add crates/node-tests/ +git commit -m "test: update signet-node-tests for HostNotifier API" +``` + +--- + +### Task 14: Full workspace verification + +- [ ] **Step 1: Run full workspace clippy** + +Run: `cargo clippy --workspace --all-features --all-targets` +Expected: Clean pass. + +- [ ] **Step 2: Run full workspace tests** + +Run: `cargo t --workspace` +Expected: All tests pass. + +- [ ] **Step 3: Format** + +Run: `cargo +nightly fmt` +Expected: No changes (already formatted). + +- [ ] **Step 4: Final commit if any formatting changes** + +```bash +git add -A +git commit -m "chore: final formatting pass" +``` + +--- + +## File Map Summary + +| Action | Path | Purpose | +|--------|------|---------| +| Modify | `../sdk/crates/extract/src/trait.rs` | Add `first_number`, `tip_number`, `len`, `is_empty` to `Extractable` | +| Modify | `Cargo.toml` (workspace) | Uncomment SDK path patches, add new crate deps | +| Create | `crates/node-types/` | New crate: `HostNotifier`, `HostNotification`, `HostNotificationKind` | +| Create | `crates/host-reth/` | New crate: `RethHostNotifier`, `RethChain`, alias oracle, config helpers | +| Modify | `crates/node/Cargo.toml` | Remove reth deps, add signet-node-types | +| Modify | `crates/node/src/node.rs` | Replace `ExExContext` with `HostNotifier` | +| Modify | `crates/node/src/builder.rs` | Update builder for new API | +| Modify | `crates/node/src/metrics.rs` | Use `HostNotification` types | +| Modify | `crates/node/src/rpc.rs` | Use pre-built configs | +| Delete | `crates/node/src/alias.rs` | Moved to signet-host-reth | +| Modify | `crates/node/src/lib.rs` | Remove alias re-exports | +| Modify | `crates/node-config/src/rpc.rs` | Remove reth-dependent methods | +| Modify | `crates/node-config/Cargo.toml` | Remove reth deps | +| Modify | `crates/node-tests/Cargo.toml` | Add signet-host-reth dep | +| Modify | `crates/node-tests/src/context.rs` | Use `decompose_exex_context` | From da68defb066f0a25e7d0ae75f21934b41f0ef67f Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 12:17:59 -0400 Subject: [PATCH 03/19] docs: change Extractable metadata methods to return Option first_number and tip_number now return Option instead of panicking on empty chain segments. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-13-host-context-adapter.md | 39 ++++++------------- .../2026-03-13-host-context-adapter-design.md | 31 ++++----------- 2 files changed, 20 insertions(+), 50 deletions(-) diff --git a/docs/superpowers/plans/2026-03-13-host-context-adapter.md b/docs/superpowers/plans/2026-03-13-host-context-adapter.md index e9bca78..98a89cb 100644 --- a/docs/superpowers/plans/2026-03-13-host-context-adapter.md +++ b/docs/superpowers/plans/2026-03-13-host-context-adapter.md @@ -27,30 +27,15 @@ The `Extractable` trait lives in the external SDK repo at `../sdk/crates/extract In `../sdk/crates/extract/src/trait.rs`, add after the `blocks_and_receipts` method: ```rust -/// Block number of the first block in the segment. -/// -/// # Panics -/// -/// Panics if the chain segment is empty. -fn first_number(&self) -> u64 { - self.blocks_and_receipts() - .next() - .expect("chain segment is empty") - .0 - .number() +/// Block number of the first block in the segment, or `None` if empty. +fn first_number(&self) -> Option { + self.blocks_and_receipts().next().map(|(b, _)| b.number()) } -/// Block number of the tip (last block) in the segment. -/// -/// # Panics -/// -/// Panics if the chain segment is empty. -fn tip_number(&self) -> u64 { - self.blocks_and_receipts() - .last() - .expect("chain segment is empty") - .0 - .number() +/// Block number of the tip (last block) in the segment, or `None` if +/// empty. +fn tip_number(&self) -> Option { + self.blocks_and_receipts().last().map(|(b, _)| b.number()) } /// Number of blocks in the segment. @@ -454,12 +439,12 @@ impl Extractable for RethChain { ExtractableChainShim::new(&self.inner).blocks_and_receipts() } - fn first_number(&self) -> u64 { - self.inner.first().number() + fn first_number(&self) -> Option { + Some(self.inner.first().number()) } - fn tip_number(&self) -> u64 { - self.inner.tip().number() + fn tip_number(&self) -> Option { + Some(self.inner.tip().number()) } fn len(&self) -> usize { @@ -892,7 +877,7 @@ Replace the entire file. Key changes: - `notification.reverted_chain()` → `notification.kind.reverted_chain()` - `notification.committed_chain()` → `notification.kind.committed_chain()` - `process_committed_chain` takes `&Arc` where `N::Chain: Extractable`. Remove the `ExtractableChainShim::new(chain)` call — the chain already implements `Extractable` directly. Use the chain as-is with `Extractor::extract_signet`. -- `on_host_revert` takes `&Arc`, uses `chain.first_number()` and `chain.tip_number()` +- `on_host_revert` takes `&Arc`, uses `chain.first_number()` and `chain.tip_number()` (both return `Option` — unwrap or early-return on `None`) - **Decompose `update_status`:** Split into two parts: - `update_status_channel(ru_height)` — just updates `self.status.send_modify(...)`. Called from `on_notification` as before. - `update_block_tags(safe_block_number, finalized_block_number)` — takes the bundled values from the notification. Called from `start_inner` AFTER `on_notification` returns. diff --git a/docs/superpowers/specs/2026-03-13-host-context-adapter-design.md b/docs/superpowers/specs/2026-03-13-host-context-adapter-design.md index 652b617..f5bbfb2 100644 --- a/docs/superpowers/specs/2026-03-13-host-context-adapter-design.md +++ b/docs/superpowers/specs/2026-03-13-host-context-adapter-design.md @@ -51,30 +51,15 @@ pub trait Extractable: Debug + Sync { &self, ) -> impl Iterator)>; - /// Block number of the first block in the segment. - /// - /// # Panics - /// - /// Panics if the chain segment is empty. - fn first_number(&self) -> u64 { - self.blocks_and_receipts() - .next() - .expect("chain segment is empty") - .0 - .number() + /// Block number of the first block in the segment, or `None` if empty. + fn first_number(&self) -> Option { + self.blocks_and_receipts().next().map(|(b, _)| b.number()) } - /// Block number of the tip (last block) in the segment. - /// - /// # Panics - /// - /// Panics if the chain segment is empty. - fn tip_number(&self) -> u64 { - self.blocks_and_receipts() - .last() - .expect("chain segment is empty") - .0 - .number() + /// Block number of the tip (last block) in the segment, or `None` if + /// empty. + fn tip_number(&self) -> Option { + self.blocks_and_receipts().last().map(|(b, _)| b.number()) } /// Number of blocks in the segment. @@ -386,7 +371,7 @@ The `signet-node` RPC module receives pre-built config values only. | Lookups bundled into notification | Safe/finalized numbers travel with the notification; hash resolution is the backend's job. Structurally prevents inconsistent reads during processing | | `set_head` and `send_finished_height` take `u64` | Backend resolves block hashes internally; signet-node never queries the host chain | | Associated types for `Chain` | Flexibility for backends with different chain representations | -| Chain metadata merged into `Extractable` | `first_number`, `tip_number`, `len` are derivable from `blocks_and_receipts`; eliminates a separate `HostChain` trait. Panics on empty match current reth behavior | +| Chain metadata merged into `Extractable` | `first_number`, `tip_number`, `len` are derivable from `blocks_and_receipts`; eliminates a separate `HostChain` trait. Returns `Option` for empty safety | | Reth impl in dedicated crate | Keeps all reth deps isolated; signet-node is reth-free | | RPC config extracted at call site | Simpler than threading config through the trait; eliminates reth dep from signet-node-config | | `CacheHandle` passed into builder | Moves pool dependency out of signet-node; caller constructs blob cacher | From db92e38e250f9632beb13afcb4d30fb8a9f373da Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 12:27:11 -0400 Subject: [PATCH 04/19] chore: enable local SDK path overrides for Extractable changes Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8df690a..ed489f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,14 +119,14 @@ signet-hot = { git = "https://github.com/init4tech/storage.git", branch = "james signet-storage = { git = "https://github.com/init4tech/storage.git", branch = "james/eng-1978" } signet-storage-types = { git = "https://github.com/init4tech/storage.git", branch = "james/eng-1978" } -# signet-bundle = { path = "../sdk/crates/bundle"} -# signet-constants = { path = "../sdk/crates/constants"} -# signet-evm = { path = "../sdk/crates/evm"} -# signet-extract = { path = "../sdk/crates/extract"} -# signet-journal = { path = "../sdk/crates/journal"} -# signet-test-utils = { path = "../sdk/crates/test-utils"} -# signet-tx-cache = { path = "../sdk/crates/tx-cache"} -# signet-types = { path = "../sdk/crates/types"} -# signet-zenith = { path = "../sdk/crates/zenith"} +signet-bundle = { path = "../sdk/crates/bundle"} +signet-constants = { path = "../sdk/crates/constants"} +signet-evm = { path = "../sdk/crates/evm"} +signet-extract = { path = "../sdk/crates/extract"} +signet-journal = { path = "../sdk/crates/journal"} +signet-test-utils = { path = "../sdk/crates/test-utils"} +signet-tx-cache = { path = "../sdk/crates/tx-cache"} +signet-types = { path = "../sdk/crates/types"} +signet-zenith = { path = "../sdk/crates/zenith"} # init4-bin-base = { path = "../shared" } From 9d8248a9ee5edc5c946c58539d6a39dde7b7e009 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 12:29:50 -0400 Subject: [PATCH 05/19] feat: add signet-node-types crate with HostNotifier trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the signet-node-types crate containing HostNotification, HostNotificationKind, and the HostNotifier trait — the core abstraction layer for host chain events with no reth dependencies. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 1 + crates/node-types/Cargo.toml | 13 +++++ crates/node-types/README.md | 3 ++ crates/node-types/src/lib.rs | 18 +++++++ crates/node-types/src/notification.rs | 70 +++++++++++++++++++++++++++ crates/node-types/src/notifier.rs | 36 ++++++++++++++ 6 files changed, 141 insertions(+) create mode 100644 crates/node-types/Cargo.toml create mode 100644 crates/node-types/README.md create mode 100644 crates/node-types/src/lib.rs create mode 100644 crates/node-types/src/notification.rs create mode 100644 crates/node-types/src/notifier.rs diff --git a/Cargo.toml b/Cargo.toml index ed489f2..7f35a23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ signet-genesis = { version = "0.16.0-rc.7", path = "crates/genesis" } signet-node = { version = "0.16.0-rc.7", path = "crates/node" } signet-node-config = { version = "0.16.0-rc.7", path = "crates/node-config" } signet-node-tests = { version = "0.16.0-rc.7", path = "crates/node-tests" } +signet-node-types = { version = "0.16.0-rc.7", path = "crates/node-types" } signet-rpc = { version = "0.16.0-rc.7", path = "crates/rpc" } init4-bin-base = { version = "0.18.0-rc.8", features = ["alloy"] } diff --git a/crates/node-types/Cargo.toml b/crates/node-types/Cargo.toml new file mode 100644 index 0000000..6815a66 --- /dev/null +++ b/crates/node-types/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "signet-node-types" +description = "Trait abstractions for the signet node's host chain interface." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +signet-extract.workspace = true diff --git a/crates/node-types/README.md b/crates/node-types/README.md new file mode 100644 index 0000000..5f02143 --- /dev/null +++ b/crates/node-types/README.md @@ -0,0 +1,3 @@ +# signet-node-types + +Trait abstractions for the signet node's host chain interface. diff --git a/crates/node-types/src/lib.rs b/crates/node-types/src/lib.rs new file mode 100644 index 0000000..6572742 --- /dev/null +++ b/crates/node-types/src/lib.rs @@ -0,0 +1,18 @@ +#![doc = include_str!("../README.md")] +#![warn( + missing_copy_implementations, + missing_debug_implementations, + missing_docs, + unreachable_pub, + clippy::missing_const_for_fn, + rustdoc::all +)] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![deny(unused_must_use, rust_2018_idioms)] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] + +mod notification; +pub use notification::{HostNotification, HostNotificationKind}; + +mod notifier; +pub use notifier::HostNotifier; diff --git a/crates/node-types/src/notification.rs b/crates/node-types/src/notification.rs new file mode 100644 index 0000000..9a99095 --- /dev/null +++ b/crates/node-types/src/notification.rs @@ -0,0 +1,70 @@ +use signet_extract::Extractable; +use std::sync::Arc; + +/// A notification from the host chain, bundling a chain event with +/// point-in-time block tag data. +#[derive(Debug, Clone)] +pub struct HostNotification { + /// The chain event (commit, revert, or reorg). + pub kind: HostNotificationKind, + /// The host chain "safe" block number at the time of this notification. + pub safe_block_number: Option, + /// The host chain "finalized" block number at the time of this + /// notification. + pub finalized_block_number: Option, +} + +/// The kind of chain event in a [`HostNotification`]. +#[derive(Debug, Clone)] +pub enum HostNotificationKind { + /// A new chain segment was committed. + ChainCommitted { + /// The newly committed chain segment. + new: Arc, + }, + /// A chain segment was reverted. + ChainReverted { + /// The reverted chain segment. + old: Arc, + }, + /// A chain reorg occurred: one segment was reverted and replaced by + /// another. + ChainReorged { + /// The reverted chain segment. + old: Arc, + /// The newly committed chain segment. + new: Arc, + }, +} + +impl HostNotificationKind { + /// Returns the committed chain, if any. + /// + /// Returns `Some` for [`ChainCommitted`] and [`ChainReorged`], `None` + /// for [`ChainReverted`]. + /// + /// [`ChainCommitted`]: HostNotificationKind::ChainCommitted + /// [`ChainReorged`]: HostNotificationKind::ChainReorged + /// [`ChainReverted`]: HostNotificationKind::ChainReverted + pub const fn committed_chain(&self) -> Option<&Arc> { + match self { + Self::ChainCommitted { new } | Self::ChainReorged { new, .. } => Some(new), + Self::ChainReverted { .. } => None, + } + } + + /// Returns the reverted chain, if any. + /// + /// Returns `Some` for [`ChainReverted`] and [`ChainReorged`], `None` + /// for [`ChainCommitted`]. + /// + /// [`ChainReverted`]: HostNotificationKind::ChainReverted + /// [`ChainReorged`]: HostNotificationKind::ChainReorged + /// [`ChainCommitted`]: HostNotificationKind::ChainCommitted + pub const fn reverted_chain(&self) -> Option<&Arc> { + match self { + Self::ChainReverted { old } | Self::ChainReorged { old, .. } => Some(old), + Self::ChainCommitted { .. } => None, + } + } +} diff --git a/crates/node-types/src/notifier.rs b/crates/node-types/src/notifier.rs new file mode 100644 index 0000000..42a73a6 --- /dev/null +++ b/crates/node-types/src/notifier.rs @@ -0,0 +1,36 @@ +use crate::HostNotification; +use core::future::Future; +use signet_extract::Extractable; + +/// Abstraction over a host chain notification source. +/// +/// Drives the signet node's main loop: yielding chain events, controlling +/// backfill, and sending feedback. All block data comes from notifications; +/// the backend handles hash resolution internally. +/// +/// # Implementors +/// +/// - `signet-host-reth`: wraps reth's `ExExContext` +pub trait HostNotifier { + /// A chain segment — contiguous blocks with receipts. + type Chain: Extractable; + + /// The error type for fallible operations. + type Error: core::error::Error + Send + Sync + 'static; + + /// Yield the next notification. `None` signals host shutdown. + fn next_notification( + &mut self, + ) -> impl Future, Self::Error>>> + Send; + + /// Set the head position, requesting backfill from this block number. + /// The backend resolves the block number to a block hash internally. + fn set_head(&mut self, block_number: u64); + + /// Configure backfill batch size limits. + fn set_backfill_thresholds(&mut self, max_blocks: Option); + + /// Signal that processing is complete up to this host block number. + /// The backend resolves the block number to a block hash internally. + fn send_finished_height(&self, block_number: u64) -> Result<(), Self::Error>; +} From 993b67104ede4599140e05e0481d36a10f861c5d Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 12:46:28 -0400 Subject: [PATCH 06/19] feat: add signet-host-reth crate with RethHostNotifier Introduces the signet-host-reth crate which isolates all reth ExEx dependencies behind the HostNotifier trait. This includes: - RethChain: owning wrapper around reth's Chain implementing Extractable with O(1) metadata accessors via inlined transmute logic - RethHostNotifier: ExEx-backed implementation of HostNotifier that handles hash resolution internally - RethAliasOracle/Factory: moved from signet-node for reuse - RPC config helpers: rpc_config_from_args and serve_config_from_args - decompose_exex_context: splits ExExContext into notifier + config - RethHostError: proper error type satisfying core::error::Error Also re-exports RecoveredBlockShim from signet-blobber. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 1 + crates/blobber/src/lib.rs | 2 +- crates/host-reth/Cargo.toml | 30 ++++++ crates/host-reth/README.md | 3 + crates/host-reth/src/alias.rs | 79 +++++++++++++++ crates/host-reth/src/chain.rs | 57 +++++++++++ crates/host-reth/src/config.rs | 34 +++++++ crates/host-reth/src/error.rs | 24 +++++ crates/host-reth/src/lib.rs | 26 +++++ crates/host-reth/src/notifier.rs | 167 +++++++++++++++++++++++++++++++ 10 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 crates/host-reth/Cargo.toml create mode 100644 crates/host-reth/README.md create mode 100644 crates/host-reth/src/alias.rs create mode 100644 crates/host-reth/src/chain.rs create mode 100644 crates/host-reth/src/config.rs create mode 100644 crates/host-reth/src/error.rs create mode 100644 crates/host-reth/src/lib.rs create mode 100644 crates/host-reth/src/notifier.rs diff --git a/Cargo.toml b/Cargo.toml index 7f35a23..4b630f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ incremental = false signet-blobber = { version = "0.16.0-rc.7", path = "crates/blobber" } signet-block-processor = { version = "0.16.0-rc.7", path = "crates/block-processor" } signet-genesis = { version = "0.16.0-rc.7", path = "crates/genesis" } +signet-host-reth = { version = "0.16.0-rc.7", path = "crates/host-reth" } signet-node = { version = "0.16.0-rc.7", path = "crates/node" } signet-node-config = { version = "0.16.0-rc.7", path = "crates/node-config" } signet-node-tests = { version = "0.16.0-rc.7", path = "crates/node-tests" } diff --git a/crates/blobber/src/lib.rs b/crates/blobber/src/lib.rs index ef6dcca..511d4ff 100644 --- a/crates/blobber/src/lib.rs +++ b/crates/blobber/src/lib.rs @@ -24,7 +24,7 @@ mod error; pub use error::{BlobberError, BlobberResult}; mod shim; -pub use shim::ExtractableChainShim; +pub use shim::{ExtractableChainShim, RecoveredBlockShim}; #[cfg(test)] mod test { diff --git a/crates/host-reth/Cargo.toml b/crates/host-reth/Cargo.toml new file mode 100644 index 0000000..b9efe47 --- /dev/null +++ b/crates/host-reth/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "signet-host-reth" +description = "Reth ExEx implementation of the `HostNotifier` trait for signet-node." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +signet-node-types.workspace = true +signet-blobber.workspace = true +signet-extract.workspace = true +signet-rpc.workspace = true +signet-block-processor.workspace = true +signet-types.workspace = true + +alloy.workspace = true +reth.workspace = true +reth-exex.workspace = true +reth-node-api.workspace = true +reth-stages-types.workspace = true + +eyre.workspace = true +futures-util.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true diff --git a/crates/host-reth/README.md b/crates/host-reth/README.md new file mode 100644 index 0000000..c381b88 --- /dev/null +++ b/crates/host-reth/README.md @@ -0,0 +1,3 @@ +# signet-host-reth + +Reth ExEx implementation of the `HostNotifier` trait for signet-node. diff --git a/crates/host-reth/src/alias.rs b/crates/host-reth/src/alias.rs new file mode 100644 index 0000000..8a06344 --- /dev/null +++ b/crates/host-reth/src/alias.rs @@ -0,0 +1,79 @@ +use alloy::{consensus::constants::KECCAK_EMPTY, primitives::Address}; +use core::fmt; +use eyre::OptionExt; +use reth::providers::{StateProviderBox, StateProviderFactory}; +use signet_block_processor::{AliasOracle, AliasOracleFactory}; + +/// An [`AliasOracle`] backed by a reth [`StateProviderBox`]. +/// +/// Checks whether an address has non-delegation bytecode, indicating it +/// should be aliased during transaction processing. +pub struct RethAliasOracle(StateProviderBox); + +impl fmt::Debug for RethAliasOracle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RethAliasOracle").finish_non_exhaustive() + } +} + +impl AliasOracle for RethAliasOracle { + fn should_alias(&self, address: Address) -> eyre::Result { + // No account at this address. + let Some(acct) = self.0.basic_account(&address)? else { return Ok(false) }; + // Get the bytecode hash for this account. + let bch = match acct.bytecode_hash { + Some(hash) => hash, + // No bytecode hash; not a contract. + None => return Ok(false), + }; + // No code at this address. + if bch == KECCAK_EMPTY { + return Ok(false); + } + // Fetch the code associated with this bytecode hash. + let code = self + .0 + .bytecode_by_hash(&bch)? + .ok_or_eyre("code not found. This indicates a corrupted database")?; + + // If not a 7702 delegation contract, alias it. + Ok(!code.is_eip7702()) + } +} + +/// An [`AliasOracleFactory`] backed by a `Box`. +/// +/// Creates [`RethAliasOracle`] instances from the latest host chain state. +pub struct RethAliasOracleFactory(Box); + +impl fmt::Debug for RethAliasOracleFactory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RethAliasOracleFactory").finish_non_exhaustive() + } +} + +impl RethAliasOracleFactory { + /// Create a new [`RethAliasOracleFactory`] from a boxed state provider + /// factory. + pub fn new(provider: Box) -> Self { + Self(provider) + } +} + +impl AliasOracleFactory for RethAliasOracleFactory { + type Oracle = RethAliasOracle; + + fn create(&self) -> eyre::Result { + // NB: This becomes a problem if anyone ever birthday attacks a + // contract/EOA pair (c.f. EIP-3607). In practice this is unlikely to + // happen for the foreseeable future, and if it does we can revisit + // this decision. + // We considered taking the host height as an argument to this method, + // but this would require all nodes to be archive nodes in order to + // sync, which is less than ideal + self.0 + .state_by_block_number_or_tag(alloy::eips::BlockNumberOrTag::Latest) + .map(RethAliasOracle) + .map_err(Into::into) + } +} diff --git a/crates/host-reth/src/chain.rs b/crates/host-reth/src/chain.rs new file mode 100644 index 0000000..c368c5a --- /dev/null +++ b/crates/host-reth/src/chain.rs @@ -0,0 +1,57 @@ +use alloy::{consensus::Block, consensus::BlockHeader}; +use reth::primitives::{EthPrimitives, RecoveredBlock}; +use reth::providers::Chain; +use signet_blobber::RecoveredBlockShim; +use signet_extract::Extractable; +use signet_types::primitives::TransactionSigned; +use std::sync::Arc; + +/// Reth's recovered block type, aliased for readability. +type RethRecovered = RecoveredBlock>; + +/// An owning wrapper around reth's [`Chain`] that implements [`Extractable`] +/// with O(1) metadata accessors. +#[derive(Debug)] +pub struct RethChain { + inner: Arc>, +} + +impl RethChain { + /// Wrap a reth chain. + pub const fn new(chain: Arc>) -> Self { + Self { inner: chain } + } +} + +impl Extractable for RethChain { + type Block = RecoveredBlockShim; + type Receipt = reth::primitives::Receipt; + + fn blocks_and_receipts(&self) -> impl Iterator)> { + self.inner.blocks_and_receipts().map(|(block, receipts)| { + // SAFETY: `RecoveredBlockShim` is `#[repr(transparent)]` over + // `RethRecovered`, so these types have identical memory layouts. + // The lifetime of the reference is tied to `self.inner` (the + // `Arc`), which outlives the returned iterator. + let block = + unsafe { std::mem::transmute::<&RethRecovered, &RecoveredBlockShim>(block) }; + (block, receipts) + }) + } + + fn first_number(&self) -> Option { + Some(self.inner.first().number()) + } + + fn tip_number(&self) -> Option { + Some(self.inner.tip().number()) + } + + fn len(&self) -> usize { + self.inner.len() + } + + fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} diff --git a/crates/host-reth/src/config.rs b/crates/host-reth/src/config.rs new file mode 100644 index 0000000..f5334f7 --- /dev/null +++ b/crates/host-reth/src/config.rs @@ -0,0 +1,34 @@ +use reth::args::RpcServerArgs; +use signet_rpc::{ServeConfig, StorageRpcConfig}; +use std::net::SocketAddr; + +/// Extract [`StorageRpcConfig`] values from reth's host RPC settings. +/// +/// Fields with no reth equivalent retain their defaults. +pub fn rpc_config_from_args(args: &RpcServerArgs) -> StorageRpcConfig { + let gpo = &args.gas_price_oracle; + StorageRpcConfig::builder() + .rpc_gas_cap(args.rpc_gas_cap) + .max_tracing_requests(args.rpc_max_tracing_requests) + .gas_oracle_block_count(gpo.blocks as u64) + .gas_oracle_percentile(gpo.percentile as f64) + .ignore_price(Some(gpo.ignore_price as u128)) + .max_price(Some(gpo.max_price as u128)) + .build() +} + +/// Convert reth [`RpcServerArgs`] into a reth-free [`ServeConfig`]. +pub fn serve_config_from_args(args: &RpcServerArgs) -> ServeConfig { + let http = + if args.http { vec![SocketAddr::from((args.http_addr, args.http_port))] } else { vec![] }; + let ws = if args.ws { vec![SocketAddr::from((args.ws_addr, args.ws_port))] } else { vec![] }; + let ipc = if !args.ipcdisable { Some(args.ipcpath.clone()) } else { None }; + + ServeConfig { + http, + http_cors: args.http_corsdomain.clone(), + ws, + ws_cors: args.ws_allowed_origins.clone(), + ipc, + } +} diff --git a/crates/host-reth/src/error.rs b/crates/host-reth/src/error.rs new file mode 100644 index 0000000..1391db2 --- /dev/null +++ b/crates/host-reth/src/error.rs @@ -0,0 +1,24 @@ +use reth_exex::ExExEvent; + +/// Errors from the [`RethHostNotifier`](crate::RethHostNotifier). +#[derive(Debug, thiserror::Error)] +pub enum RethHostError { + /// A notification stream error forwarded from reth. + #[error("notification stream error: {0}")] + Notification(#[source] Box), + /// The provider failed to look up a header or block tag. + #[error("provider error: {0}")] + Provider(#[from] reth::providers::ProviderError), + /// Failed to send an ExEx event back to the host. + #[error("failed to send ExEx event")] + EventSend(#[from] tokio::sync::mpsc::error::SendError), + /// A required header was missing from the provider. + #[error("missing header for block {0}")] + MissingHeader(u64), +} + +impl From for RethHostError { + fn from(e: eyre::Report) -> Self { + Self::Notification(e.into()) + } +} diff --git a/crates/host-reth/src/lib.rs b/crates/host-reth/src/lib.rs new file mode 100644 index 0000000..3ff1e46 --- /dev/null +++ b/crates/host-reth/src/lib.rs @@ -0,0 +1,26 @@ +#![doc = include_str!("../README.md")] +#![warn( + missing_copy_implementations, + missing_debug_implementations, + missing_docs, + unreachable_pub, + clippy::missing_const_for_fn, + rustdoc::all +)] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![deny(unused_must_use, rust_2018_idioms)] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] + +mod alias; +pub use alias::{RethAliasOracle, RethAliasOracleFactory}; +mod error; +pub use error::RethHostError; + +mod chain; +pub use chain::RethChain; + +mod config; +pub use config::{rpc_config_from_args, serve_config_from_args}; + +mod notifier; +pub use notifier::{DecomposedContext, RethHostNotifier, decompose_exex_context}; diff --git a/crates/host-reth/src/notifier.rs b/crates/host-reth/src/notifier.rs new file mode 100644 index 0000000..6d0cab0 --- /dev/null +++ b/crates/host-reth/src/notifier.rs @@ -0,0 +1,167 @@ +use crate::{ + RethChain, + config::{rpc_config_from_args, serve_config_from_args}, + error::RethHostError, +}; +use alloy::eips::BlockNumHash; +use futures_util::StreamExt; +use reth::{ + chainspec::EthChainSpec, + primitives::EthPrimitives, + providers::{BlockIdReader, BlockReader, HeaderProvider}, +}; +use reth_exex::{ExExContext, ExExEvent, ExExNotifications, ExExNotificationsStream}; +use reth_node_api::{FullNodeComponents, NodeTypes}; +use reth_stages_types::ExecutionStageThresholds; +use signet_node_types::{HostNotification, HostNotificationKind, HostNotifier}; +use signet_rpc::{ServeConfig, StorageRpcConfig}; +use std::sync::Arc; +use tracing::debug; + +/// Reth ExEx implementation of [`HostNotifier`]. +/// +/// Wraps reth's notification stream, provider, and event sender. All hash +/// resolution happens internally — consumers only work with block numbers. +pub struct RethHostNotifier { + notifications: ExExNotifications, + provider: Host::Provider, + events: tokio::sync::mpsc::UnboundedSender, +} + +impl core::fmt::Debug for RethHostNotifier { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RethHostNotifier").finish_non_exhaustive() + } +} + +/// The output of [`decompose_exex_context`]. +pub struct DecomposedContext { + /// The host notifier adapter. + pub notifier: RethHostNotifier, + /// Plain RPC serve config. + pub serve_config: ServeConfig, + /// Plain RPC storage config. + pub rpc_config: StorageRpcConfig, + /// The transaction pool, for blob cacher construction. + pub pool: Host::Pool, + /// The chain name, for tracing. + pub chain_name: String, +} + +impl core::fmt::Debug for DecomposedContext { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("DecomposedContext") + .field("chain_name", &self.chain_name) + .finish_non_exhaustive() + } +} + +/// Decompose a reth [`ExExContext`] into a [`RethHostNotifier`] and +/// associated configuration values. +/// +/// This splits the ExEx context into: +/// - A [`RethHostNotifier`] (implements [`HostNotifier`]) +/// - A [`ServeConfig`] (plain RPC server config) +/// - A [`StorageRpcConfig`] (gas oracle settings) +/// - The transaction pool handle +/// - A chain name for tracing +pub fn decompose_exex_context(ctx: ExExContext) -> DecomposedContext +where + Host: FullNodeComponents, + Host::Types: NodeTypes, +{ + let chain_name = ctx.config.chain.chain().to_string(); + let serve_config = serve_config_from_args(&ctx.config.rpc); + let rpc_config = rpc_config_from_args(&ctx.config.rpc); + let pool = ctx.pool().clone(); + let provider = ctx.provider().clone(); + + let notifier = + RethHostNotifier { notifications: ctx.notifications, provider, events: ctx.events }; + + DecomposedContext { notifier, serve_config, rpc_config, pool, chain_name } +} + +impl HostNotifier for RethHostNotifier +where + Host: FullNodeComponents, + Host::Types: NodeTypes, +{ + type Chain = RethChain; + type Error = RethHostError; + + async fn next_notification( + &mut self, + ) -> Option, Self::Error>> { + let notification = self.notifications.next().await?; + let notification = match notification { + Ok(n) => n, + Err(e) => return Some(Err(e.into())), + }; + + // Read safe/finalized from the provider at notification time. + let safe_block_number = self.provider.safe_block_number().ok().flatten(); + let finalized_block_number = self.provider.finalized_block_number().ok().flatten(); + + let kind = match notification { + reth_exex::ExExNotification::ChainCommitted { new } => { + HostNotificationKind::ChainCommitted { new: Arc::new(RethChain::new(new)) } + } + reth_exex::ExExNotification::ChainReverted { old } => { + HostNotificationKind::ChainReverted { old: Arc::new(RethChain::new(old)) } + } + reth_exex::ExExNotification::ChainReorged { old, new } => { + HostNotificationKind::ChainReorged { + old: Arc::new(RethChain::new(old)), + new: Arc::new(RethChain::new(new)), + } + } + }; + + Some(Ok(HostNotification { kind, safe_block_number, finalized_block_number })) + } + + fn set_head(&mut self, block_number: u64) { + let block = self + .provider + .block_by_number(block_number) + .expect("failed to look up block for set_head"); + + let head = match block { + Some(b) => b.num_hash_slow(), + None => { + debug!(block_number, "block not found for set_head, falling back to genesis"); + let genesis = self + .provider + .block_by_number(0) + .expect("failed to look up genesis block") + .expect("genesis block missing"); + genesis.num_hash_slow() + } + }; + + let exex_head = reth_exex::ExExHead { block: head }; + self.notifications.set_with_head(exex_head); + } + + fn set_backfill_thresholds(&mut self, max_blocks: Option) { + if let Some(max_blocks) = max_blocks { + self.notifications.set_backfill_thresholds(ExecutionStageThresholds { + max_blocks: Some(max_blocks), + ..Default::default() + }); + debug!(max_blocks, "configured backfill thresholds"); + } + } + + fn send_finished_height(&self, block_number: u64) -> Result<(), Self::Error> { + let header = self + .provider + .sealed_header(block_number)? + .ok_or(RethHostError::MissingHeader(block_number))?; + + let hash = header.hash(); + self.events.send(ExExEvent::FinishedHeight(BlockNumHash { number: block_number, hash }))?; + Ok(()) + } +} From 4f81b4ff1d9943afd4faf4e20774325cda8c089d Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 13:07:52 -0400 Subject: [PATCH 07/19] refactor(node-config): remove ExEx dependency Co-Authored-By: Claude Sonnet 4.6 --- crates/node-config/Cargo.toml | 2 -- crates/node-config/src/core.rs | 2 +- crates/node-config/src/lib.rs | 2 -- crates/node-config/src/rpc.rs | 29 ----------------------------- 4 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 crates/node-config/src/rpc.rs diff --git a/crates/node-config/Cargo.toml b/crates/node-config/Cargo.toml index 877f503..f906131 100644 --- a/crates/node-config/Cargo.toml +++ b/crates/node-config/Cargo.toml @@ -18,8 +18,6 @@ init4-bin-base.workspace = true reth.workspace = true reth-chainspec.workspace = true -reth-exex.workspace = true -reth-node-api.workspace = true alloy.workspace = true eyre.workspace = true diff --git a/crates/node-config/src/core.rs b/crates/node-config/src/core.rs index 2bb5840..185f7fd 100644 --- a/crates/node-config/src/core.rs +++ b/crates/node-config/src/core.rs @@ -1,9 +1,9 @@ use crate::StorageConfig; use alloy::genesis::Genesis; use init4_bin_base::utils::{calc::SlotCalculator, from_env::FromEnv}; +use reth::primitives::NodePrimitives; use reth::providers::providers::StaticFileProvider; use reth_chainspec::ChainSpec; -use reth_node_api::NodePrimitives; use signet_blobber::BlobFetcherConfig; use signet_genesis::GenesisSpec; use signet_types::constants::{ConfigError, SignetSystemConstants}; diff --git a/crates/node-config/src/lib.rs b/crates/node-config/src/lib.rs index 26071cc..d86f1f5 100644 --- a/crates/node-config/src/lib.rs +++ b/crates/node-config/src/lib.rs @@ -14,8 +14,6 @@ mod core; pub use core::{SIGNET_NODE_DEFAULT_HTTP_PORT, SignetNodeConfig}; -mod rpc; - mod storage; pub use storage::StorageConfig; diff --git a/crates/node-config/src/rpc.rs b/crates/node-config/src/rpc.rs deleted file mode 100644 index 44e4258..0000000 --- a/crates/node-config/src/rpc.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::SignetNodeConfig; -use reth::args::RpcServerArgs; -use reth_exex::ExExContext; -use reth_node_api::FullNodeComponents; - -impl SignetNodeConfig { - /// Inherits the IP host address from the Reth RPC server configuration, - /// and change the configured port for the RPC server. If the host server - /// is configured to use IPC, Signet Node will use the endpoint specified by the - /// environment variable `IPC_ENDPOINT`. - fn modify_args(&self, sc: &RpcServerArgs) -> eyre::Result { - let mut args = sc.clone(); - - args.http_port = self.http_port(); - args.ws_port = self.ws_port(); - args.ipcpath = self.ipc_endpoint().map(ToOwned::to_owned).unwrap_or_default(); - - Ok(args) - } - - /// Merges Signet Node configurations over the transport and rpc server - /// configurations, and returns the modified configs. - pub fn merge_rpc_configs(&self, exex: &ExExContext) -> eyre::Result - where - Node: FullNodeComponents, - { - self.modify_args(&exex.config.rpc) - } -} From ae36a74efd4c79d306438e05dbbe678aaba1ef0c Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 13:09:18 -0400 Subject: [PATCH 08/19] refactor(node): replace ExExContext with HostNotifier trait signet-node is now reth-free. All host chain interaction flows through the HostNotifier trait from signet-node-types. Co-Authored-By: Claude Opus 4.6 --- crates/node/Cargo.toml | 13 +- crates/node/src/alias.rs | 79 ---------- crates/node/src/builder.rs | 154 ++++++++++--------- crates/node/src/lib.rs | 3 - crates/node/src/metrics.rs | 12 +- crates/node/src/node.rs | 305 +++++++++++++++---------------------- crates/node/src/rpc.rs | 45 +----- 7 files changed, 222 insertions(+), 389 deletions(-) delete mode 100644 crates/node/src/alias.rs diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index ad24182..29292a9 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -9,31 +9,24 @@ homepage.workspace = true repository.workspace = true [dependencies] +signet-blobber.workspace = true signet-block-processor.workspace = true signet-cold.workspace = true signet-evm.workspace = true signet-extract.workspace = true signet-genesis.workspace = true +signet-hot.workspace = true signet-node-config.workspace = true +signet-node-types.workspace = true signet-rpc.workspace = true -signet-hot.workspace = true signet-storage.workspace = true - -signet-blobber.workspace = true signet-tx-cache.workspace = true signet-types.workspace = true alloy.workspace = true - -reth.workspace = true -reth-exex.workspace = true -reth-node-api.workspace = true -reth-stages-types.workspace = true - trevm.workspace = true eyre.workspace = true -futures-util.workspace = true metrics.workspace = true reqwest.workspace = true tokio.workspace = true diff --git a/crates/node/src/alias.rs b/crates/node/src/alias.rs deleted file mode 100644 index 8a06344..0000000 --- a/crates/node/src/alias.rs +++ /dev/null @@ -1,79 +0,0 @@ -use alloy::{consensus::constants::KECCAK_EMPTY, primitives::Address}; -use core::fmt; -use eyre::OptionExt; -use reth::providers::{StateProviderBox, StateProviderFactory}; -use signet_block_processor::{AliasOracle, AliasOracleFactory}; - -/// An [`AliasOracle`] backed by a reth [`StateProviderBox`]. -/// -/// Checks whether an address has non-delegation bytecode, indicating it -/// should be aliased during transaction processing. -pub struct RethAliasOracle(StateProviderBox); - -impl fmt::Debug for RethAliasOracle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("RethAliasOracle").finish_non_exhaustive() - } -} - -impl AliasOracle for RethAliasOracle { - fn should_alias(&self, address: Address) -> eyre::Result { - // No account at this address. - let Some(acct) = self.0.basic_account(&address)? else { return Ok(false) }; - // Get the bytecode hash for this account. - let bch = match acct.bytecode_hash { - Some(hash) => hash, - // No bytecode hash; not a contract. - None => return Ok(false), - }; - // No code at this address. - if bch == KECCAK_EMPTY { - return Ok(false); - } - // Fetch the code associated with this bytecode hash. - let code = self - .0 - .bytecode_by_hash(&bch)? - .ok_or_eyre("code not found. This indicates a corrupted database")?; - - // If not a 7702 delegation contract, alias it. - Ok(!code.is_eip7702()) - } -} - -/// An [`AliasOracleFactory`] backed by a `Box`. -/// -/// Creates [`RethAliasOracle`] instances from the latest host chain state. -pub struct RethAliasOracleFactory(Box); - -impl fmt::Debug for RethAliasOracleFactory { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("RethAliasOracleFactory").finish_non_exhaustive() - } -} - -impl RethAliasOracleFactory { - /// Create a new [`RethAliasOracleFactory`] from a boxed state provider - /// factory. - pub fn new(provider: Box) -> Self { - Self(provider) - } -} - -impl AliasOracleFactory for RethAliasOracleFactory { - type Oracle = RethAliasOracle; - - fn create(&self) -> eyre::Result { - // NB: This becomes a problem if anyone ever birthday attacks a - // contract/EOA pair (c.f. EIP-3607). In practice this is unlikely to - // happen for the foreseeable future, and if it does we can revisit - // this decision. - // We considered taking the host height as an argument to this method, - // but this would require all nodes to be archive nodes in order to - // sync, which is less than ideal - self.0 - .state_by_block_number_or_tag(alloy::eips::BlockNumberOrTag::Latest) - .map(RethAliasOracle) - .map_err(Into::into) - } -} diff --git a/crates/node/src/builder.rs b/crates/node/src/builder.rs index 490c542..1629dd9 100644 --- a/crates/node/src/builder.rs +++ b/crates/node/src/builder.rs @@ -1,14 +1,14 @@ #![allow(clippy::type_complexity)] -use crate::{NodeStatus, RethAliasOracleFactory, SignetNode}; +use crate::{NodeStatus, SignetNode}; use eyre::OptionExt; -use reth::{primitives::EthPrimitives, providers::StateProviderFactory}; -use reth_exex::ExExContext; -use reth_node_api::{FullNodeComponents, NodeTypes}; +use signet_blobber::CacheHandle; use signet_block_processor::AliasOracleFactory; use signet_cold::BlockData; use signet_hot::db::{HotDbRead, UnsafeDbWrite}; use signet_node_config::SignetNodeConfig; +use signet_node_types::HostNotifier; +use signet_rpc::{ServeConfig, StorageRpcConfig}; use signet_storage::{HistoryRead, HistoryWrite, HotKv, HotKvRead, UnifiedStorage}; use std::sync::Arc; use tracing::info; @@ -25,22 +25,27 @@ pub struct NotAStorage; /// Builder for [`SignetNode`]. This is the main way to create a signet node. /// /// The builder requires the following components to be set before building: -/// - An [`ExExContext`], via [`Self::with_ctx`]. +/// - A [`HostNotifier`], via [`Self::with_notifier`]. /// - An [`Arc>`], via [`Self::with_storage`]. /// - An [`AliasOracleFactory`], via [`Self::with_alias_oracle`]. -/// - If not set, a default one will be created from the [`ExExContext`]'s -/// provider. +/// - A [`CacheHandle`], via [`Self::with_blob_cacher`]. +/// - A [`ServeConfig`], via [`Self::with_serve_config`]. +/// - A [`StorageRpcConfig`], via [`Self::with_rpc_config`]. /// - A `reqwest::Client`, via [`Self::with_client`]. /// - If not set, a default client will be created. -pub struct SignetNodeBuilder { +pub struct SignetNodeBuilder { config: SignetNodeConfig, alias_oracle: Option, - ctx: Option, + notifier: Option, storage: Option, client: Option, + chain_name: Option, + blob_cacher: Option, + serve_config: Option, + rpc_config: Option, } -impl core::fmt::Debug for SignetNodeBuilder { +impl core::fmt::Debug for SignetNodeBuilder { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("SignetNodeBuilder").finish_non_exhaustive() } @@ -49,40 +54,51 @@ impl core::fmt::Debug for SignetNodeBuilder Self { - Self { config, alias_oracle: None, ctx: None, storage: None, client: None } + Self { + config, + alias_oracle: None, + notifier: None, + storage: None, + client: None, + chain_name: None, + blob_cacher: None, + serve_config: None, + rpc_config: None, + } } } -impl SignetNodeBuilder { +impl SignetNodeBuilder { /// Set the [`UnifiedStorage`] backend for the signet node. pub fn with_storage( self, storage: Arc>, - ) -> SignetNodeBuilder>, Aof> { + ) -> SignetNodeBuilder>, Aof> { SignetNodeBuilder { config: self.config, alias_oracle: self.alias_oracle, - ctx: self.ctx, + notifier: self.notifier, storage: Some(storage), client: self.client, + chain_name: self.chain_name, + blob_cacher: self.blob_cacher, + serve_config: self.serve_config, + rpc_config: self.rpc_config, } } - /// Set the [`ExExContext`] for the signet node. - pub fn with_ctx( - self, - ctx: ExExContext, - ) -> SignetNodeBuilder, Storage, Aof> - where - NewHost: FullNodeComponents, - NewHost::Types: NodeTypes, - { + /// Set the [`HostNotifier`] for the signet node. + pub fn with_notifier(self, notifier: N) -> SignetNodeBuilder { SignetNodeBuilder { config: self.config, alias_oracle: self.alias_oracle, - ctx: Some(ctx), + notifier: Some(notifier), storage: self.storage, client: self.client, + chain_name: self.chain_name, + blob_cacher: self.blob_cacher, + serve_config: self.serve_config, + rpc_config: self.rpc_config, } } @@ -90,13 +106,17 @@ impl SignetNodeBuilder { pub fn with_alias_oracle( self, alias_oracle: NewAof, - ) -> SignetNodeBuilder { + ) -> SignetNodeBuilder { SignetNodeBuilder { config: self.config, alias_oracle: Some(alias_oracle), - ctx: self.ctx, + notifier: self.notifier, storage: self.storage, client: self.client, + chain_name: self.chain_name, + blob_cacher: self.blob_cacher, + serve_config: self.serve_config, + rpc_config: self.rpc_config, } } @@ -105,19 +125,44 @@ impl SignetNodeBuilder { self.client = Some(client); self } + + /// Set the human-readable chain name for logging. + pub fn with_chain_name(mut self, chain_name: String) -> Self { + self.chain_name = Some(chain_name); + self + } + + /// Set the pre-built blob cacher handle. + pub fn with_blob_cacher(mut self, blob_cacher: CacheHandle) -> Self { + self.blob_cacher = Some(blob_cacher); + self + } + + /// Set the RPC transport configuration. + pub fn with_serve_config(mut self, serve_config: ServeConfig) -> Self { + self.serve_config = Some(serve_config); + self + } + + /// Set the RPC behaviour configuration. + pub const fn with_rpc_config(mut self, rpc_config: StorageRpcConfig) -> Self { + self.rpc_config = Some(rpc_config); + self + } } -impl SignetNodeBuilder, Arc>, Aof> +impl SignetNodeBuilder>, Aof> where - Host: FullNodeComponents, - Host::Types: NodeTypes, - H: HotKv, + N: HostNotifier, + H: HotKv + Clone + Send + Sync + 'static, + ::Error: DBErrorMarker, + Aof: AliasOracleFactory, { /// Prebuild checks for the signet node builder. Shared by all build /// commands. async fn prebuild(&mut self) -> eyre::Result<()> { self.client.get_or_insert_default(); - self.ctx.as_ref().ok_or_eyre("Launch context must be set")?; + self.notifier.as_ref().ok_or_eyre("Notifier must be set")?; let storage = self.storage.as_ref().ok_or_eyre("Storage must be set")?; // Load genesis into hot storage if absent. @@ -150,60 +195,25 @@ where Ok(()) } -} -impl SignetNodeBuilder, Arc>, NotAnAof> -where - Host: FullNodeComponents, - Host::Types: NodeTypes, - H: HotKv + Clone + Send + Sync + 'static, - ::Error: DBErrorMarker, -{ - /// Build the node. This performs the following steps: - /// - /// - Runs prebuild checks. - /// - Inits storage from genesis if needed. - /// - Creates a default `AliasOracleFactory` from the host DB. - pub async fn build( - mut self, - ) -> eyre::Result<(SignetNode, tokio::sync::watch::Receiver)> { - self.prebuild().await?; - let ctx = self.ctx.unwrap(); - let provider = ctx.provider().clone(); - let alias_oracle = RethAliasOracleFactory::new(Box::new(provider)); - - SignetNode::new_unsafe( - ctx, - self.config, - self.storage.unwrap(), - alias_oracle, - self.client.unwrap(), - ) - } -} - -impl SignetNodeBuilder, Arc>, Aof> -where - Host: FullNodeComponents, - Host::Types: NodeTypes, - H: HotKv + Clone + Send + Sync + 'static, - ::Error: DBErrorMarker, - Aof: AliasOracleFactory, -{ /// Build the node. This performs the following steps: /// /// - Runs prebuild checks. /// - Inits storage from genesis if needed. pub async fn build( mut self, - ) -> eyre::Result<(SignetNode, tokio::sync::watch::Receiver)> { + ) -> eyre::Result<(SignetNode, tokio::sync::watch::Receiver)> { self.prebuild().await?; SignetNode::new_unsafe( - self.ctx.unwrap(), + self.notifier.unwrap(), self.config, self.storage.unwrap(), self.alias_oracle.unwrap(), self.client.unwrap(), + self.chain_name.unwrap_or_default(), + self.blob_cacher.ok_or_eyre("Blob cacher must be set")?, + self.serve_config.ok_or_eyre("Serve config must be set")?, + self.rpc_config.ok_or_eyre("RPC config must be set")?, ) } } diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index c8e7c60..4af3110 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -11,9 +11,6 @@ #![deny(unused_must_use, rust_2018_idioms)] #![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] -mod alias; -pub use alias::{RethAliasOracle, RethAliasOracleFactory}; - mod builder; pub use builder::SignetNodeBuilder; diff --git a/crates/node/src/metrics.rs b/crates/node/src/metrics.rs index d775464..ded5c5d 100644 --- a/crates/node/src/metrics.rs +++ b/crates/node/src/metrics.rs @@ -7,8 +7,8 @@ //! - Number of reorgs processed use metrics::{Counter, counter, describe_counter}; -use reth::primitives::NodePrimitives; -use reth_exex::ExExNotification; +use signet_extract::Extractable; +use signet_node_types::HostNotification; use std::sync::LazyLock; const NOTIFICATION_RECEIVED: &str = "signet.node.notification_received"; @@ -66,16 +66,16 @@ fn inc_reorgs_processed() { reorgs_processed().increment(1); } -pub(crate) fn record_notification_received(notification: &ExExNotification) { +pub(crate) fn record_notification_received(notification: &HostNotification) { inc_notifications_received(); - if notification.reverted_chain().is_some() { + if notification.kind.reverted_chain().is_some() { inc_reorgs_received(); } } -pub(crate) fn record_notification_processed(notification: &ExExNotification) { +pub(crate) fn record_notification_processed(notification: &HostNotification) { inc_notifications_processed(); - if notification.reverted_chain().is_some() { + if notification.kind.reverted_chain().is_some() { inc_reorgs_processed(); } } diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs index aabf803..861ae8b 100644 --- a/crates/node/src/node.rs +++ b/crates/node/src/node.rs @@ -1,21 +1,16 @@ -use crate::{NodeStatus, RethAliasOracleFactory, metrics}; +use crate::{NodeStatus, metrics}; use alloy::consensus::BlockHeader; use eyre::Context; -use futures_util::StreamExt; -use reth::{ - chainspec::EthChainSpec, - primitives::EthPrimitives, - providers::{BlockIdReader, BlockReader, HeaderProvider}, -}; -use reth_exex::{ExExContext, ExExEvent, ExExHead, ExExNotificationsStream}; -use reth_node_api::{FullNodeComponents, FullNodeTypes, NodeTypes}; -use reth_stages_types::ExecutionStageThresholds; -use signet_blobber::{CacheHandle, ExtractableChainShim}; +use signet_blobber::CacheHandle; use signet_block_processor::{AliasOracleFactory, SignetBlockProcessorV1}; use signet_evm::EthereumHardfork; -use signet_extract::Extractor; +use signet_extract::{Extractable, Extractor}; use signet_node_config::SignetNodeConfig; -use signet_rpc::{ChainNotifier, NewBlockNotification, ReorgNotification, RpcServerGuard}; +use signet_node_types::{HostNotification, HostNotifier}; +use signet_rpc::{ + ChainNotifier, NewBlockNotification, ReorgNotification, RpcServerGuard, ServeConfig, + StorageRpcConfig, +}; use signet_storage::{DrainedBlock, HistoryRead, HotKv, HotKvRead, UnifiedStorage}; use signet_types::{PairedHeights, constants::SignetSystemConstants}; use std::{fmt, sync::Arc}; @@ -23,20 +18,17 @@ use tokio::sync::watch; use tracing::{debug, info, instrument}; use trevm::revm::database::DBErrorMarker; -/// Type alias for the host primitives. -type PrimitivesOf = <::Types as NodeTypes>::Primitives; -type ExExNotification = reth_exex::ExExNotification>; -type Chain = reth::providers::Chain>; - /// Signet context and configuration. -pub struct SignetNode +pub struct SignetNode where - Host: FullNodeComponents, - Host::Types: NodeTypes, + N: HostNotifier, H: HotKv, { - /// The host context, which manages provider access and notifications. - pub(crate) host: ExExContext, + /// The host notifier, which yields chain notifications. + pub(crate) notifier: N, + + /// Human-readable chain name for logging. + pub(crate) chain_name: String, /// Signet node configuration. pub(crate) config: Arc, @@ -66,12 +58,17 @@ where /// A reqwest client, used by the blob fetch and the tx cache forwarder. pub(crate) client: reqwest::Client, + + /// RPC transport configuration. + pub(crate) serve_config: ServeConfig, + + /// RPC behaviour configuration. + pub(crate) rpc_config: StorageRpcConfig, } -impl fmt::Debug for SignetNode +impl fmt::Debug for SignetNode where - Host: FullNodeComponents, - Host::Types: NodeTypes, + N: HostNotifier, H: HotKv, { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -79,10 +76,9 @@ where } } -impl SignetNode +impl SignetNode where - Host: FullNodeComponents, - Host::Types: NodeTypes, + N: HostNotifier, H: HotKv + Clone + Send + Sync + 'static, ::Error: DBErrorMarker, AliasOracle: AliasOracleFactory, @@ -100,12 +96,17 @@ where /// /// [`SignetNodeBuilder`]: crate::builder::SignetNodeBuilder #[doc(hidden)] + #[allow(clippy::too_many_arguments)] pub fn new_unsafe( - ctx: ExExContext, + notifier: N, config: SignetNodeConfig, storage: Arc>, alias_oracle: AliasOracle, client: reqwest::Client, + chain_name: String, + blob_cacher: CacheHandle, + serve_config: ServeConfig, + rpc_config: StorageRpcConfig, ) -> eyre::Result<(Self, watch::Receiver)> { let constants = config.constants().wrap_err("failed to load signet constants from genesis")?; @@ -113,17 +114,10 @@ where let (status, receiver) = watch::channel(NodeStatus::Booting); let chain = ChainNotifier::new(128); - let blob_cacher = signet_blobber::BlobFetcher::builder() - .with_config(config.block_extractor())? - .with_pool(ctx.pool().clone()) - .with_client(client.clone()) - .build_cache() - .wrap_err("failed to create blob cacher")? - .spawn(); - let this = Self { config: config.into(), - host: ctx, + notifier, + chain_name, storage, chain, rpc_handle: None, @@ -132,6 +126,8 @@ where alias_oracle: Arc::new(alias_oracle), blob_cacher, client, + serve_config, + rpc_config, }; Ok((this, receiver)) } @@ -142,9 +138,9 @@ where Ok(reader.last_block_number()?.unwrap_or(0)) } - /// Start the Signet instance, listening for ExEx notifications. Trace any + /// Start the Signet instance, listening for host notifications. Trace any /// errors. - #[instrument(skip(self), fields(host = ?self.host.config.chain.chain()))] + #[instrument(skip(self), fields(host = %self.chain_name))] pub async fn start(mut self) -> eyre::Result<()> { // Ensure hot and cold storage are at the same height. If either // is ahead, unwind to the minimum so the host re-delivers blocks. @@ -173,13 +169,12 @@ where let last_block = self.storage.reader().ok().and_then(|r| r.last_block_number().ok().flatten()); - let exex_head = last_block.and_then(|h| self.set_exex_head(h).ok()); - tracing::error!(err, last_block, ?exex_head, "Signet node crashed"); + tracing::error!(err, last_block, "Signet node crashed"); }) } - /// Start the Signet instance, listening for ExEx notifications. + /// Start the Signet instance, listening for host notifications. async fn start_inner(&mut self) -> eyre::Result<()> { debug!(constants = ?self.constants, "signet starting"); @@ -193,135 +188,82 @@ where // Update the node status channel with last block height self.status.send_modify(|s| *s = NodeStatus::AtHeight(last_rollup_block)); - // Sets the ExEx head position relative to that last block - let exex_head = self.set_exex_head(last_rollup_block)?; + // Set the head position and backfill thresholds on the notifier + let host_height = if last_rollup_block == 0 { + self.constants.host_deploy_height() + } else { + self.constants.pair_ru(last_rollup_block).host + }; + self.notifier.set_head(host_height); + self.notifier.set_backfill_thresholds(self.config.backfill_max_blocks()); + info!( - host_head = exex_head.block.number, - host_hash = %exex_head.block.hash, + host_height, rollup_head_height = last_rollup_block, "signet listening for notifications" ); - // Handle incoming ExEx notifications - while let Some(notification) = self.host.notifications.next().await { - let notification = notification.wrap_err("error in reth host notifications stream")?; - self.on_notification(notification) + // Handle incoming host notifications + while let Some(notification) = self.notifier.next_notification().await { + let notification = notification.wrap_err("error in host notifications stream")?; + let changed = self + .on_notification(¬ification) .await .wrap_err("error while processing notification")?; + if changed { + self.update_block_tags( + notification.safe_block_number, + notification.finalized_block_number, + )?; + } } info!("signet shutting down"); Ok(()) } - /// Sets the head of the Exex chain from the last rollup block, handling - /// genesis conditions if necessary. - fn set_exex_head(&mut self, last_rollup_block: u64) -> eyre::Result { - // If the last rollup block is 0, shortcut to the host rollup - // deployment block. - if last_rollup_block == 0 { - let host_deployment_block = - self.host.provider().block_by_number(self.constants.host_deploy_height())?; - match host_deployment_block { - Some(genesis_block) => { - let exex_head = ExExHead { block: genesis_block.num_hash_slow() }; - self.host.notifications.set_with_head(exex_head); - self.set_backfill_thresholds(); - return Ok(exex_head); - } - None => { - let host_ru_deploy_block = self.constants.host_deploy_height(); - debug!( - host_ru_deploy_block, - "Host deploy height not found. Falling back to genesis block" - ); - let genesis_block = self - .host - .provider() - .block_by_number(0)? - .expect("failed to find genesis block"); - let exex_head = ExExHead { block: genesis_block.num_hash_slow() }; - self.host.notifications.set_with_head(exex_head); - self.set_backfill_thresholds(); - return Ok(exex_head); - } - } - } - - // Find the corresponding host block for the rollup block number. - let host_height = self.constants.pair_ru(last_rollup_block).host; - - match self.host.provider().block_by_number(host_height)? { - Some(host_block) => { - debug!(host_height, "found host block for height"); - let exex_head = ExExHead { block: host_block.num_hash_slow() }; - self.host.notifications.set_with_head(exex_head); - self.set_backfill_thresholds(); - Ok(exex_head) - } - None => { - debug!(host_height, "no host block found for host height"); - let genesis_block = - self.host.provider().block_by_number(0)?.expect("failed to find genesis block"); - let exex_head = ExExHead { block: genesis_block.num_hash_slow() }; - self.host.notifications.set_with_head(exex_head); - self.set_backfill_thresholds(); - Ok(exex_head) - } - } - } - - /// Sets backfill thresholds to limit memory usage during sync. - /// This should be called after `set_with_head` to configure how many - /// blocks can be processed per backfill batch. - fn set_backfill_thresholds(&mut self) { - if let Some(max_blocks) = self.config.backfill_max_blocks() { - self.host.notifications.set_backfill_thresholds(ExecutionStageThresholds { - max_blocks: Some(max_blocks), - ..Default::default() - }); - debug!(max_blocks, "configured backfill thresholds"); - } - } - - /// Runs on any notification received from the ExEx context. + /// Runs on any notification received from the host. + /// + /// Returns `true` if any rollup state changed. #[instrument(parent = None, skip_all, fields( - reverted = notification.reverted_chain().map(|c| c.len()).unwrap_or_default(), - committed = notification.committed_chain().map(|c| c.len()).unwrap_or_default(), + reverted = notification.kind.reverted_chain().map(|c| c.len()).unwrap_or_default(), + committed = notification.kind.committed_chain().map(|c| c.len()).unwrap_or_default(), ))] - pub async fn on_notification(&self, notification: ExExNotification) -> eyre::Result<()> { - metrics::record_notification_received(¬ification); + pub async fn on_notification( + &self, + notification: &HostNotification, + ) -> eyre::Result { + metrics::record_notification_received(notification); let mut changed = false; // NB: REVERTS MUST RUN FIRST - if let Some(chain) = notification.reverted_chain() { + if let Some(chain) = notification.kind.reverted_chain() { changed |= - self.on_host_revert(&chain).await.wrap_err("error encountered during revert")?; + self.on_host_revert(chain).await.wrap_err("error encountered during revert")?; } - if let Some(chain) = notification.committed_chain() { + if let Some(chain) = notification.kind.committed_chain() { changed |= self - .process_committed_chain(&chain) + .process_committed_chain(chain) .await .wrap_err("error encountered during commit")?; } if changed { - self.update_status()?; + self.update_status_channel()?; } - metrics::record_notification_processed(¬ification); - Ok(()) + metrics::record_notification_processed(notification); + Ok(changed) } /// Process a committed chain by extracting and executing blocks. /// /// Returns `true` if any rollup blocks were processed. - async fn process_committed_chain(&self, chain: &Arc>) -> eyre::Result { - let shim = ExtractableChainShim::new(chain); + async fn process_committed_chain(&self, chain: &Arc) -> eyre::Result { let extractor = Extractor::new(self.constants.clone()); - let extracts: Vec<_> = extractor.extract_signet(&shim).collect(); + let extracts: Vec<_> = extractor.extract_signet(chain.as_ref()).collect(); let last_height = self.last_rollup_block()?; @@ -375,26 +317,30 @@ where let _ = self.chain.send_reorg(notif); } - /// Update the status channel and block tags. This keeps the RPC node - /// in sync with the latest block information. - fn update_status(&self) -> eyre::Result<()> { + /// Update the status channel with the current rollup height. + fn update_status_channel(&self) -> eyre::Result<()> { let ru_height = self.last_rollup_block()?; - - self.update_block_tags(ru_height)?; self.status.send_modify(|s| *s = NodeStatus::AtHeight(ru_height)); Ok(()) } - /// Update block tags (latest/safe/finalized) and notify reth of processed - /// height. - fn update_block_tags(&self, ru_height: u64) -> eyre::Result<()> { + /// Update block tags (latest/safe/finalized) and notify the host of + /// processed height. + fn update_block_tags( + &self, + safe_block_number: Option, + finalized_block_number: Option, + ) -> eyre::Result<()> { + let ru_height = self.last_rollup_block()?; + // Safe height - let safe_heights = self.load_safe_block_heights(ru_height)?; + let safe_heights = self.load_safe_block_heights(ru_height, safe_block_number); let safe_ru_height = safe_heights.rollup; debug!(safe_ru_height, "calculated safe ru height"); // Finalized height - let finalized_heights = self.load_finalized_block_heights(ru_height)?; + let finalized_heights = + self.load_finalized_block_heights(ru_height, finalized_block_number); debug!( finalized_host_height = finalized_heights.host, finalized_ru_height = finalized_heights.rollup, @@ -404,7 +350,7 @@ where // Atomically update all three tags self.chain.tags().update_all(ru_height, safe_ru_height, finalized_heights.rollup); - // Notify reth that we've finished processing up to the finalized + // Notify the host that we've finished processing up to the finalized // height. Skip if finalized rollup height is still at genesis. if finalized_heights.rollup > 0 { self.update_highest_processed_height(finalized_heights.host)?; @@ -427,19 +373,21 @@ where /// 2. The safe rollup equivalent is beyond the current rollup height. /// 3. The safe rollup equivalent is below the current rollup height (normal /// case). - fn load_safe_block_heights(&self, ru_height: u64) -> eyre::Result { - let Some(safe_heights) = - self.host.provider().safe_block_number()?.and_then(|h| self.constants.pair_host(h)) - else { + fn load_safe_block_heights( + &self, + ru_height: u64, + safe_block_number: Option, + ) -> PairedHeights { + let Some(safe_heights) = safe_block_number.and_then(|h| self.constants.pair_host(h)) else { // Host safe block is below rollup genesis — use genesis. - return Ok(PairedHeights { host: self.constants.host_deploy_height(), rollup: 0 }); + return PairedHeights { host: self.constants.host_deploy_height(), rollup: 0 }; }; // Clamp to current rollup height if ahead. if safe_heights.rollup > ru_height { - Ok(self.constants.pair_ru(ru_height)) + self.constants.pair_ru(ru_height) } else { - Ok(safe_heights) + safe_heights } } @@ -451,56 +399,51 @@ where /// 2. The finalized rollup equivalent is beyond the current rollup height. /// 3. The finalized rollup equivalent is below the current rollup height /// (normal case). - fn load_finalized_block_heights(&self, ru_height: u64) -> eyre::Result { - let Some(finalized_ru) = self - .host - .provider() - .finalized_block_number()? - .and_then(|h| self.constants.host_block_to_rollup_block_num(h)) + fn load_finalized_block_heights( + &self, + ru_height: u64, + finalized_block_number: Option, + ) -> PairedHeights { + let Some(finalized_ru) = + finalized_block_number.and_then(|h| self.constants.host_block_to_rollup_block_num(h)) else { // Host finalized block is below rollup genesis — use genesis. - return Ok(PairedHeights { host: self.constants.host_deploy_height(), rollup: 0 }); + return PairedHeights { host: self.constants.host_deploy_height(), rollup: 0 }; }; // Clamp to current rollup height if ahead. let ru = finalized_ru.min(ru_height); - Ok(self.constants.pair_ru(ru)) + self.constants.pair_ru(ru) } - /// Update the host node with the highest processed host height for the - /// ExEx. + /// Update the host node with the highest processed host height. fn update_highest_processed_height(&self, finalized_host_height: u64) -> eyre::Result<()> { let adjusted_height = finalized_host_height.saturating_sub(1); - let adjusted_header = self - .host - .provider() - .sealed_header(adjusted_height)? - .expect("db inconsistent. no host header for adjusted height"); - - let hash = adjusted_header.hash(); - debug!(finalized_host_height = adjusted_height, "Sending FinishedHeight notification"); - self.host.events.send(ExExEvent::FinishedHeight(alloy::eips::NumHash { - number: adjusted_height, - hash, - }))?; + self.notifier.send_finished_height(adjusted_height).map_err(|e| eyre::eyre!(e))?; Ok(()) } /// Called when the host chain has reverted a block or set of blocks. /// /// Returns `true` if any rollup state was unwound. - #[instrument(skip_all, fields(first = chain.first().number(), tip = chain.tip().number()))] - pub async fn on_host_revert(&self, chain: &Arc>) -> eyre::Result { + #[instrument(skip_all, fields( + first = chain.first_number().unwrap_or(0), + tip = chain.tip_number().unwrap_or(0), + ))] + pub async fn on_host_revert(&self, chain: &Arc) -> eyre::Result { + let tip = chain.tip_number().unwrap_or(0); + let first = chain.first_number().unwrap_or(0); + // If the end is before the RU genesis, nothing to do. - if chain.tip().number() <= self.constants.host_deploy_height() { + if tip <= self.constants.host_deploy_height() { return Ok(false); } // Target is the block BEFORE the first block in the chain, or 0. let target = self .constants - .host_block_to_rollup_block_num(chain.first().number()) + .host_block_to_rollup_block_num(first) .unwrap_or_default() .saturating_sub(1); diff --git a/crates/node/src/rpc.rs b/crates/node/src/rpc.rs index 6ed6ef9..e143f97 100644 --- a/crates/node/src/rpc.rs +++ b/crates/node/src/rpc.rs @@ -1,17 +1,15 @@ use crate::SignetNode; -use reth::{args::RpcServerArgs, primitives::EthPrimitives}; -use reth_node_api::{FullNodeComponents, NodeTypes}; use signet_block_processor::AliasOracleFactory; -use signet_rpc::{RpcServerGuard, ServeConfig, StorageRpcConfig, StorageRpcCtx}; +use signet_node_types::HostNotifier; +use signet_rpc::{RpcServerGuard, StorageRpcCtx}; use signet_storage::HotKv; use signet_tx_cache::TxCache; -use std::{net::SocketAddr, sync::Arc}; +use std::sync::Arc; use tracing::info; -impl SignetNode +impl SignetNode where - Host: FullNodeComponents, - Host::Types: NodeTypes, + N: HostNotifier, H: HotKv + Send + Sync + 'static, ::Error: trevm::revm::database::DBErrorMarker, AliasOracle: AliasOracleFactory, @@ -28,45 +26,16 @@ where let tx_cache = self.config.forward_url().map(|url| TxCache::new_with_client(url, self.client.clone())); - let args = self.config.merge_rpc_configs(&self.host)?; - let rpc_config = rpc_config_from_args(&args); - let rpc_ctx = StorageRpcCtx::new( Arc::clone(&self.storage), self.constants.clone(), self.config.genesis().config.clone(), self.chain.clone(), tx_cache, - rpc_config, + self.rpc_config, ); let router = signet_rpc::router::().with_state(rpc_ctx); - let serve_config = serve_config_from_args(args); - serve_config.serve(router).await.map_err(Into::into) + self.serve_config.clone().serve(router).await.map_err(Into::into) } } - -/// Extract [`StorageRpcConfig`] values from reth's host RPC settings. -/// -/// Fields with no reth equivalent retain their defaults. -fn rpc_config_from_args(args: &RpcServerArgs) -> StorageRpcConfig { - let gpo = &args.gas_price_oracle; - StorageRpcConfig::builder() - .rpc_gas_cap(args.rpc_gas_cap) - .max_tracing_requests(args.rpc_max_tracing_requests) - .gas_oracle_block_count(gpo.blocks as u64) - .gas_oracle_percentile(gpo.percentile as f64) - .ignore_price(Some(gpo.ignore_price as u128)) - .max_price(Some(gpo.max_price as u128)) - .build() -} - -/// Convert reth [`RpcServerArgs`] into a reth-free [`ServeConfig`]. -fn serve_config_from_args(args: RpcServerArgs) -> ServeConfig { - let http = - if args.http { vec![SocketAddr::from((args.http_addr, args.http_port))] } else { vec![] }; - let ws = if args.ws { vec![SocketAddr::from((args.ws_addr, args.ws_port))] } else { vec![] }; - let ipc = if !args.ipcdisable { Some(args.ipcpath) } else { None }; - - ServeConfig { http, http_cors: args.http_corsdomain, ws, ws_cors: args.ws_allowed_origins, ipc } -} From 9a67a64e9679a14d0fb0e5d9d6f10180c35805ff Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 13:09:23 -0400 Subject: [PATCH 09/19] refactor: make block processor generic over Extractable process_block and run_evm now accept any Extractable chain type, removing the hard dependency on ExtractableChainShim. Co-Authored-By: Claude Opus 4.6 --- crates/blobber/src/blobs/cache.rs | 11 +++++------ crates/block-processor/src/v1/processor.rs | 12 ++++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/blobber/src/blobs/cache.rs b/crates/blobber/src/blobs/cache.rs index 81c67f9..96b9e96 100644 --- a/crates/blobber/src/blobs/cache.rs +++ b/crates/blobber/src/blobs/cache.rs @@ -4,8 +4,7 @@ use alloy::eips::eip7691::MAX_BLOBS_PER_BLOCK_ELECTRA; use alloy::eips::merge::EPOCH_SLOTS; use alloy::primitives::{B256, Bytes, keccak256}; use core::fmt; -use reth::transaction_pool::TransactionPool; -use reth::{network::cache::LruMap, primitives::Receipt}; +use reth::{network::cache::LruMap, transaction_pool::TransactionPool}; use signet_extract::ExtractedEvent; use signet_zenith::Zenith::BlockSubmitted; use signet_zenith::ZenithBlock; @@ -75,10 +74,10 @@ impl CacheHandle { /// Fetch the blobs using [`Self::fetch_blobs`] and decode them to get the /// Zenith block data using the provided coder. - pub async fn fetch_and_decode( + pub async fn fetch_and_decode( &self, slot: usize, - extract: &ExtractedEvent<'_, Receipt, BlockSubmitted>, + extract: &ExtractedEvent<'_, R, BlockSubmitted>, ) -> BlobberResult where Coder: SidecarCoder + Default, @@ -116,11 +115,11 @@ impl CacheHandle { /// decoded (e.g., due to a malformatted blob). /// - `Err(FetchError)` if there was an unrecoverable error fetching the /// blobs. - pub async fn signet_block( + pub async fn signet_block( &self, host_block_number: u64, slot: usize, - extract: &ExtractedEvent<'_, Receipt, BlockSubmitted>, + extract: &ExtractedEvent<'_, R, BlockSubmitted>, ) -> FetchResult where Coder: SidecarCoder + Default, diff --git a/crates/block-processor/src/v1/processor.rs b/crates/block-processor/src/v1/processor.rs index e28588f..d57fa68 100644 --- a/crates/block-processor/src/v1/processor.rs +++ b/crates/block-processor/src/v1/processor.rs @@ -6,10 +6,10 @@ use alloy::{ use core::fmt; use eyre::{ContextCompat, WrapErr}; use init4_bin_base::utils::calc::SlotCalculator; -use signet_blobber::{CacheHandle, ExtractableChainShim}; +use signet_blobber::CacheHandle; use signet_constants::SignetSystemConstants; use signet_evm::{BlockResult, EthereumHardfork, EvmNeedsCfg, SignetDriver}; -use signet_extract::Extracts; +use signet_extract::{Extractable, Extracts}; use signet_hot::{ db::HotDbRead, model::{HotKv, HotKvRead, RevmRead}, @@ -119,9 +119,9 @@ where host_height = block_extracts.host_block.number(), has_ru_block = block_extracts.submitted.is_some(), ))] - pub async fn process_block( + pub async fn process_block( &self, - block_extracts: &Extracts<'_, ExtractableChainShim<'_>>, + block_extracts: &Extracts<'_, C>, ) -> eyre::Result { metrics::record_extracts(block_extracts); self.run_evm(block_extracts).await @@ -147,9 +147,9 @@ where /// Run the EVM for a single block extraction, returning the fully /// assembled [`ExecutedBlock`]. #[instrument(skip_all)] - async fn run_evm( + async fn run_evm( &self, - block_extracts: &Extracts<'_, ExtractableChainShim<'_>>, + block_extracts: &Extracts<'_, C>, ) -> eyre::Result { let start_time = std::time::Instant::now(); let spec_id = self.hardforks.spec_id(); From ce6fa50c6f41e6e1f6c852e5fec523f7cd96c689 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 13:09:28 -0400 Subject: [PATCH 10/19] test: update signet-node-tests for HostNotifier API Use decompose_exex_context to construct RethHostNotifier and pass it through the new builder API. Co-Authored-By: Claude Opus 4.6 --- crates/node-tests/Cargo.toml | 3 +++ crates/node-tests/src/context.rs | 21 ++++++++++++++++++++- crates/node-tests/tests/db.rs | 25 +++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/node-tests/Cargo.toml b/crates/node-tests/Cargo.toml index a6060f0..bc3ca25 100644 --- a/crates/node-tests/Cargo.toml +++ b/crates/node-tests/Cargo.toml @@ -12,10 +12,12 @@ repository.workspace = true signet-node.workspace = true signet-node-config = { workspace = true, features = ["test_utils"] } +signet-blobber.workspace = true signet-cold = { workspace = true, features = ["in-memory"] } signet-constants.workspace = true signet-evm.workspace = true signet-genesis.workspace = true +signet-host-reth.workspace = true signet-hot = { workspace = true, features = ["in-memory"] } signet-storage.workspace = true signet-storage-types.workspace = true @@ -31,6 +33,7 @@ reth-exex-test-utils.workspace = true reth-node-api.workspace = true eyre.workspace = true +reqwest.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/node-tests/src/context.rs b/crates/node-tests/src/context.rs index 190ac9f..256df34 100644 --- a/crates/node-tests/src/context.rs +++ b/crates/node-tests/src/context.rs @@ -18,6 +18,7 @@ use reth::transaction_pool::{TransactionOrigin, TransactionPool, test_utils::Moc use reth_exex_test_utils::{Adapter, TestExExHandle}; use reth_node_api::FullNodeComponents; use signet_cold::{ColdStorageReadHandle, mem::MemColdBackend}; +use signet_host_reth::decompose_exex_context; use signet_hot::{ db::{HotDbRead, UnsafeDbWrite}, mem::MemKv, @@ -104,6 +105,9 @@ impl SignetTestContext { let (ctx, handle) = reth_exex_test_utils::test_exex_context().await.unwrap(); let components = ctx.components.clone(); + // Decompose the ExEx context into notifier + configs + let decomposed = decompose_exex_context(ctx); + // set up Signet Node storage let constants = cfg.constants().unwrap(); @@ -148,10 +152,25 @@ impl SignetTestContext { let alias_oracle: Arc>> = Arc::new(Mutex::new(HashSet::default())); + // Build the blob cacher from the decomposed pool + let blob_cacher = signet_blobber::BlobFetcher::builder() + .with_config(cfg.block_extractor()) + .unwrap() + .with_pool(decomposed.pool) + .with_client(reqwest::Client::new()) + .build_cache() + .unwrap() + .spawn::(); + let (node, mut node_status) = SignetNodeBuilder::new(cfg.clone()) - .with_ctx(ctx) + .with_notifier(decomposed.notifier) .with_storage(Arc::clone(&storage)) .with_alias_oracle(Arc::clone(&alias_oracle)) + .with_chain_name(decomposed.chain_name) + .with_blob_cacher(blob_cacher) + .with_serve_config(decomposed.serve_config) + .with_rpc_config(decomposed.rpc_config) + .with_client(reqwest::Client::new()) .build() .await .unwrap(); diff --git a/crates/node-tests/tests/db.rs b/crates/node-tests/tests/db.rs index 9788342..df1dc17 100644 --- a/crates/node-tests/tests/db.rs +++ b/crates/node-tests/tests/db.rs @@ -1,5 +1,7 @@ +use alloy::primitives::map::HashSet; use serial_test::serial; use signet_cold::mem::MemColdBackend; +use signet_host_reth::decompose_exex_context; use signet_hot::{ db::{HotDbRead, UnsafeDbWrite}, mem::MemKv, @@ -7,7 +9,7 @@ use signet_hot::{ use signet_node::SignetNodeBuilder; use signet_node_config::test_utils::test_config; use signet_storage::{CancellationToken, HistoryRead, HistoryWrite, HotKv, UnifiedStorage}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; #[serial] #[tokio::test] @@ -19,6 +21,8 @@ async fn test_genesis() { let chain_spec: Arc<_> = cfg.chain_spec().clone(); assert_eq!(chain_spec.genesis().config.chain_id, consts.unwrap().ru_chain_id()); + let decomposed = decompose_exex_context(ctx); + let cancel_token = CancellationToken::new(); let hot = MemKv::new(); { @@ -30,9 +34,26 @@ async fn test_genesis() { let storage = Arc::new(UnifiedStorage::spawn(hot, MemColdBackend::new(), cancel_token.clone())); + let blob_cacher = signet_blobber::BlobFetcher::builder() + .with_config(cfg.block_extractor()) + .unwrap() + .with_pool(decomposed.pool) + .with_client(reqwest::Client::new()) + .build_cache() + .unwrap() + .spawn::(); + + let alias_oracle: Arc>> = Arc::new(Mutex::new(HashSet::default())); + let (_, _) = SignetNodeBuilder::new(cfg.clone()) - .with_ctx(ctx) + .with_notifier(decomposed.notifier) .with_storage(Arc::clone(&storage)) + .with_alias_oracle(Arc::clone(&alias_oracle)) + .with_chain_name(decomposed.chain_name) + .with_blob_cacher(blob_cacher) + .with_serve_config(decomposed.serve_config) + .with_rpc_config(decomposed.rpc_config) + .with_client(reqwest::Client::new()) .build() .await .unwrap(); From e43bee3f643f650015c8bdc9efd7e4e6d65b5ec0 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 13:13:21 -0400 Subject: [PATCH 11/19] fix(node-tests): build ServeConfig from Signet test config The decomposed reth context has IPC/HTTP disabled by default. Tests need the Signet-configured IPC path for RPC communication. Co-Authored-By: Claude Opus 4.6 --- crates/node-tests/Cargo.toml | 1 + crates/node-tests/src/context.rs | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/node-tests/Cargo.toml b/crates/node-tests/Cargo.toml index bc3ca25..358984c 100644 --- a/crates/node-tests/Cargo.toml +++ b/crates/node-tests/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true [dependencies] signet-node.workspace = true signet-node-config = { workspace = true, features = ["test_utils"] } +signet-rpc.workspace = true signet-blobber.workspace = true signet-cold = { workspace = true, features = ["in-memory"] } diff --git a/crates/node-tests/src/context.rs b/crates/node-tests/src/context.rs index 256df34..907ab0c 100644 --- a/crates/node-tests/src/context.rs +++ b/crates/node-tests/src/context.rs @@ -162,13 +162,23 @@ impl SignetTestContext { .unwrap() .spawn::(); + // Build serve config from the Signet test config rather than the + // reth defaults (which have IPC/HTTP disabled). + let serve_config = signet_rpc::ServeConfig { + http: vec![], + http_cors: None, + ws: vec![], + ws_cors: None, + ipc: cfg.ipc_endpoint().map(ToOwned::to_owned), + }; + let (node, mut node_status) = SignetNodeBuilder::new(cfg.clone()) .with_notifier(decomposed.notifier) .with_storage(Arc::clone(&storage)) .with_alias_oracle(Arc::clone(&alias_oracle)) .with_chain_name(decomposed.chain_name) .with_blob_cacher(blob_cacher) - .with_serve_config(decomposed.serve_config) + .with_serve_config(serve_config) .with_rpc_config(decomposed.rpc_config) .with_client(reqwest::Client::new()) .build() From 875672a812e017e5a097b0bedb30dc705311d2c2 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 13:39:08 -0400 Subject: [PATCH 12/19] refactor(host-reth): use sealed_header instead of num_hash_slow in set_head Eliminates num_hash_slow() calls by using the provider's sealed_header method, which returns pre-cached hashes instead of recomputing from RLP. Co-Authored-By: Claude Opus 4.6 --- crates/host-reth/src/notifier.rs | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/crates/host-reth/src/notifier.rs b/crates/host-reth/src/notifier.rs index 6d0cab0..ed2fa3c 100644 --- a/crates/host-reth/src/notifier.rs +++ b/crates/host-reth/src/notifier.rs @@ -8,7 +8,7 @@ use futures_util::StreamExt; use reth::{ chainspec::EthChainSpec, primitives::EthPrimitives, - providers::{BlockIdReader, BlockReader, HeaderProvider}, + providers::{BlockIdReader, HeaderProvider}, }; use reth_exex::{ExExContext, ExExEvent, ExExNotifications, ExExNotificationsStream}; use reth_node_api::{FullNodeComponents, NodeTypes}; @@ -122,26 +122,22 @@ where } fn set_head(&mut self, block_number: u64) { - let block = self + let head = self .provider - .block_by_number(block_number) - .expect("failed to look up block for set_head"); - - let head = match block { - Some(b) => b.num_hash_slow(), - None => { - debug!(block_number, "block not found for set_head, falling back to genesis"); + .sealed_header(block_number) + .expect("failed to look up header for set_head") + .map(|h| BlockNumHash { number: block_number, hash: h.hash() }) + .unwrap_or_else(|| { + debug!(block_number, "header not found for set_head, falling back to genesis"); let genesis = self .provider - .block_by_number(0) - .expect("failed to look up genesis block") - .expect("genesis block missing"); - genesis.num_hash_slow() - } - }; + .sealed_header(0) + .expect("failed to look up genesis header") + .expect("genesis header missing"); + BlockNumHash { number: 0, hash: genesis.hash() } + }); - let exex_head = reth_exex::ExExHead { block: head }; - self.notifications.set_with_head(exex_head); + self.notifications.set_with_head(reth_exex::ExExHead { block: head }); } fn set_backfill_thresholds(&mut self, max_blocks: Option) { From a013d3948c81afebe667ea252f27e92df7be8c76 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 13:44:03 -0400 Subject: [PATCH 13/19] chore: point SDK patch overrides to PR branch on GitHub Replace local path overrides with git references to the feat/extractable-metadata branch so CI can resolve dependencies. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4b630f0..7428709 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,14 +121,14 @@ signet-hot = { git = "https://github.com/init4tech/storage.git", branch = "james signet-storage = { git = "https://github.com/init4tech/storage.git", branch = "james/eng-1978" } signet-storage-types = { git = "https://github.com/init4tech/storage.git", branch = "james/eng-1978" } -signet-bundle = { path = "../sdk/crates/bundle"} -signet-constants = { path = "../sdk/crates/constants"} -signet-evm = { path = "../sdk/crates/evm"} -signet-extract = { path = "../sdk/crates/extract"} -signet-journal = { path = "../sdk/crates/journal"} -signet-test-utils = { path = "../sdk/crates/test-utils"} -signet-tx-cache = { path = "../sdk/crates/tx-cache"} -signet-types = { path = "../sdk/crates/types"} -signet-zenith = { path = "../sdk/crates/zenith"} +signet-bundle = { git = "https://github.com/init4tech/signet-sdk.git", branch = "feat/extractable-metadata"} +signet-constants = { git = "https://github.com/init4tech/signet-sdk.git", branch = "feat/extractable-metadata"} +signet-evm = { git = "https://github.com/init4tech/signet-sdk.git", branch = "feat/extractable-metadata"} +signet-extract = { git = "https://github.com/init4tech/signet-sdk.git", branch = "feat/extractable-metadata"} +signet-journal = { git = "https://github.com/init4tech/signet-sdk.git", branch = "feat/extractable-metadata"} +signet-test-utils = { git = "https://github.com/init4tech/signet-sdk.git", branch = "feat/extractable-metadata"} +signet-tx-cache = { git = "https://github.com/init4tech/signet-sdk.git", branch = "feat/extractable-metadata"} +signet-types = { git = "https://github.com/init4tech/signet-sdk.git", branch = "feat/extractable-metadata"} +signet-zenith = { git = "https://github.com/init4tech/signet-sdk.git", branch = "feat/extractable-metadata"} # init4-bin-base = { path = "../shared" } From 9e318dded16ecc503e920e2dc3973cdb1f2737a7 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 14:24:36 -0400 Subject: [PATCH 14/19] fix: adapt to BlockAndReceipts struct from signet-sdk update The upstream signet-sdk changed `Extractable::blocks_and_receipts` to return `BlockAndReceipts` structs instead of tuples. Update both shim implementations to construct the new struct type. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/blobber/src/shim.rs | 8 +++++--- crates/host-reth/src/chain.rs | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/blobber/src/shim.rs b/crates/blobber/src/shim.rs index a7ac340..b81038d 100644 --- a/crates/blobber/src/shim.rs +++ b/crates/blobber/src/shim.rs @@ -2,7 +2,7 @@ use alloy::consensus::Block; use reth::providers::Chain; -use signet_extract::{Extractable, HasTxns}; +use signet_extract::{BlockAndReceipts, Extractable, HasTxns}; use signet_types::primitives::TransactionSigned; /// A type alias for Reth's recovered block with a signed transaction. @@ -32,14 +32,16 @@ impl<'a> Extractable for ExtractableChainShim<'a> { type Block = RecoveredBlockShim; type Receipt = reth::primitives::Receipt; - fn blocks_and_receipts(&self) -> impl Iterator)> { + fn blocks_and_receipts( + &self, + ) -> impl Iterator> { self.chain.blocks_and_receipts().map(|(block, receipts)| { // SAFETY: because the shim is repr(transparent), the memory layout // of `RecoveredBlockShim` is the same as `RethRecovered`, so we // can safely transmute the reference. let block = unsafe { std::mem::transmute::<&'a RethRecovered, &RecoveredBlockShim>(block) }; - (block, receipts) + BlockAndReceipts { block, receipts } }) } } diff --git a/crates/host-reth/src/chain.rs b/crates/host-reth/src/chain.rs index c368c5a..cfaf34c 100644 --- a/crates/host-reth/src/chain.rs +++ b/crates/host-reth/src/chain.rs @@ -2,7 +2,7 @@ use alloy::{consensus::Block, consensus::BlockHeader}; use reth::primitives::{EthPrimitives, RecoveredBlock}; use reth::providers::Chain; use signet_blobber::RecoveredBlockShim; -use signet_extract::Extractable; +use signet_extract::{BlockAndReceipts, Extractable}; use signet_types::primitives::TransactionSigned; use std::sync::Arc; @@ -27,7 +27,9 @@ impl Extractable for RethChain { type Block = RecoveredBlockShim; type Receipt = reth::primitives::Receipt; - fn blocks_and_receipts(&self) -> impl Iterator)> { + fn blocks_and_receipts( + &self, + ) -> impl Iterator> { self.inner.blocks_and_receipts().map(|(block, receipts)| { // SAFETY: `RecoveredBlockShim` is `#[repr(transparent)]` over // `RethRecovered`, so these types have identical memory layouts. @@ -35,7 +37,7 @@ impl Extractable for RethChain { // `Arc`), which outlives the returned iterator. let block = unsafe { std::mem::transmute::<&RethRecovered, &RecoveredBlockShim>(block) }; - (block, receipts) + BlockAndReceipts { block, receipts } }) } From ea63adead04e9f51f602043a8f4255fe28827537 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 14:52:39 -0400 Subject: [PATCH 15/19] feat: add signet-host-rpc crate with RpcHostNotifier RPC-based HostNotifier implementation that follows a host chain via WebSocket newHeads subscription. Supports backfill, ring-buffer reorg detection, and epoch-aware safe/finalized block tag caching. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 1 + crates/host-rpc/Cargo.toml | 20 + crates/host-rpc/README.md | 6 + crates/host-rpc/src/builder.rs | 79 ++++ crates/host-rpc/src/error.rs | 22 + crates/host-rpc/src/lib.rs | 24 ++ crates/host-rpc/src/notifier.rs | 395 ++++++++++++++++++ crates/host-rpc/src/segment.rs | 74 ++++ .../2026-03-13-rpc-host-notifier-design.md | 386 +++++++++++++++++ 9 files changed, 1007 insertions(+) create mode 100644 crates/host-rpc/Cargo.toml create mode 100644 crates/host-rpc/README.md create mode 100644 crates/host-rpc/src/builder.rs create mode 100644 crates/host-rpc/src/error.rs create mode 100644 crates/host-rpc/src/lib.rs create mode 100644 crates/host-rpc/src/notifier.rs create mode 100644 crates/host-rpc/src/segment.rs create mode 100644 docs/superpowers/specs/2026-03-13-rpc-host-notifier-design.md diff --git a/Cargo.toml b/Cargo.toml index 7428709..c781fb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ signet-blobber = { version = "0.16.0-rc.7", path = "crates/blobber" } signet-block-processor = { version = "0.16.0-rc.7", path = "crates/block-processor" } signet-genesis = { version = "0.16.0-rc.7", path = "crates/genesis" } signet-host-reth = { version = "0.16.0-rc.7", path = "crates/host-reth" } +signet-host-rpc = { version = "0.16.0-rc.7", path = "crates/host-rpc" } signet-node = { version = "0.16.0-rc.7", path = "crates/node" } signet-node-config = { version = "0.16.0-rc.7", path = "crates/node-config" } signet-node-tests = { version = "0.16.0-rc.7", path = "crates/node-tests" } diff --git a/crates/host-rpc/Cargo.toml b/crates/host-rpc/Cargo.toml new file mode 100644 index 0000000..5cf20d0 --- /dev/null +++ b/crates/host-rpc/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "signet-host-rpc" +description = "RPC-based implementation of the HostNotifier trait for signet-node." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +signet-node-types.workspace = true +signet-extract.workspace = true +signet-types.workspace = true + +alloy.workspace = true +futures-util.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/host-rpc/README.md b/crates/host-rpc/README.md new file mode 100644 index 0000000..7d5a8a8 --- /dev/null +++ b/crates/host-rpc/README.md @@ -0,0 +1,6 @@ +# signet-host-rpc + +RPC-based implementation of the `HostNotifier` trait for signet-node. + +Connects to any Ethereum execution layer client via WebSocket, following +the host chain without embedding a full reth node. diff --git a/crates/host-rpc/src/builder.rs b/crates/host-rpc/src/builder.rs new file mode 100644 index 0000000..237d1b5 --- /dev/null +++ b/crates/host-rpc/src/builder.rs @@ -0,0 +1,79 @@ +use crate::{RpcHostError, RpcHostNotifier}; +use alloy::providers::Provider; +use std::collections::VecDeque; + +/// Default block buffer capacity. +const DEFAULT_BUFFER_CAPACITY: usize = 64; +/// Default backfill batch size. +const DEFAULT_BACKFILL_BATCH_SIZE: u64 = 32; + +/// Builder for [`RpcHostNotifier`]. +/// +/// # Example +/// +/// ```ignore +/// let notifier = RpcHostNotifierBuilder::new(provider) +/// .with_buffer_capacity(128) +/// .with_backfill_batch_size(64) +/// .build() +/// .await?; +/// ``` +#[derive(Debug)] +pub struct RpcHostNotifierBuilder

{ + provider: P, + buffer_capacity: usize, + backfill_batch_size: u64, + genesis_timestamp: u64, +} + +impl

RpcHostNotifierBuilder

+where + P: Provider + Clone, +{ + /// Create a new builder with the given provider. + pub const fn new(provider: P) -> Self { + Self { + provider, + buffer_capacity: DEFAULT_BUFFER_CAPACITY, + backfill_batch_size: DEFAULT_BACKFILL_BATCH_SIZE, + genesis_timestamp: 0, + } + } + + /// Set the block buffer capacity (default: 64). + pub const fn with_buffer_capacity(mut self, capacity: usize) -> Self { + self.buffer_capacity = capacity; + self + } + + /// Set the backfill batch size (default: 32). + pub const fn with_backfill_batch_size(mut self, batch_size: u64) -> Self { + self.backfill_batch_size = batch_size; + self + } + + /// Set the genesis timestamp for epoch calculation. + pub const fn with_genesis_timestamp(mut self, timestamp: u64) -> Self { + self.genesis_timestamp = timestamp; + self + } + + /// Build the notifier, establishing the `newHeads` WebSocket subscription. + pub async fn build(self) -> Result, RpcHostError> { + let sub = self.provider.subscribe_blocks().await?; + let header_sub = sub.into_stream(); + + Ok(RpcHostNotifier { + provider: self.provider, + header_sub, + block_buffer: VecDeque::with_capacity(self.buffer_capacity), + buffer_capacity: self.buffer_capacity, + cached_safe: None, + cached_finalized: None, + last_tag_epoch: None, + backfill_from: None, + backfill_batch_size: self.backfill_batch_size, + genesis_timestamp: self.genesis_timestamp, + }) + } +} diff --git a/crates/host-rpc/src/error.rs b/crates/host-rpc/src/error.rs new file mode 100644 index 0000000..bdfec83 --- /dev/null +++ b/crates/host-rpc/src/error.rs @@ -0,0 +1,22 @@ +use alloy::transports::{RpcError, TransportErrorKind}; + +/// Errors from the RPC host notifier. +#[derive(Debug, thiserror::Error)] +pub enum RpcHostError { + /// The WebSocket subscription was dropped unexpectedly. + #[error("subscription closed")] + SubscriptionClosed, + + /// An RPC call failed. + #[error("rpc error: {0}")] + Rpc(#[from] RpcError), + + /// Reorg deeper than the block buffer. + #[error("reorg depth {depth} exceeds buffer capacity {capacity}")] + ReorgTooDeep { + /// The detected reorg depth. + depth: u64, + /// The configured buffer capacity. + capacity: usize, + }, +} diff --git a/crates/host-rpc/src/lib.rs b/crates/host-rpc/src/lib.rs new file mode 100644 index 0000000..dad7dbb --- /dev/null +++ b/crates/host-rpc/src/lib.rs @@ -0,0 +1,24 @@ +#![doc = include_str!("../README.md")] +#![warn( + missing_copy_implementations, + missing_debug_implementations, + missing_docs, + unreachable_pub, + clippy::missing_const_for_fn, + rustdoc::all +)] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![deny(unused_must_use, rust_2018_idioms)] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] + +mod builder; +pub use builder::RpcHostNotifierBuilder; + +mod error; +pub use error::RpcHostError; + +mod notifier; +pub use notifier::RpcHostNotifier; + +mod segment; +pub use segment::{RpcBlock, RpcChainSegment}; diff --git a/crates/host-rpc/src/notifier.rs b/crates/host-rpc/src/notifier.rs new file mode 100644 index 0000000..49dd495 --- /dev/null +++ b/crates/host-rpc/src/notifier.rs @@ -0,0 +1,395 @@ +use crate::{RpcBlock, RpcChainSegment, RpcHostError}; +use alloy::{ + consensus::{BlockHeader, transaction::Recovered}, + eips::BlockNumberOrTag, + network::BlockResponse, + primitives::{B256, Sealed}, + providers::Provider, + pubsub::SubscriptionStream, + rpc::types::Header as RpcHeader, +}; +use futures_util::StreamExt; +use signet_extract::Extractable; +use signet_node_types::{HostNotification, HostNotificationKind, HostNotifier}; +use signet_types::primitives::{RecoveredBlock, SealedBlock, TransactionSigned}; +use std::{collections::VecDeque, sync::Arc}; +use tracing::debug; + +/// Seconds per Ethereum slot. +const SLOT_SECONDS: u64 = 12; +/// Slots per Ethereum epoch. +const SLOTS_PER_EPOCH: u64 = 32; + +/// RPC-based implementation of [`HostNotifier`]. +/// +/// Follows a host chain via WebSocket `newHeads` subscription, fetching full +/// blocks and receipts on demand. Detects reorgs via a ring buffer of recent +/// block hashes. +/// +/// Generic over `P`: any alloy provider that supports subscriptions. +pub struct RpcHostNotifier

{ + /// The alloy provider. + pub(crate) provider: P, + + /// Subscription stream of new block headers. + pub(crate) header_sub: SubscriptionStream, + + /// Recent blocks for reorg detection and caching. + pub(crate) block_buffer: VecDeque>, + + /// Maximum entries in the block buffer. + pub(crate) buffer_capacity: usize, + + /// Cached safe block number, refreshed at epoch boundaries. + pub(crate) cached_safe: Option, + + /// Cached finalized block number, refreshed at epoch boundaries. + pub(crate) cached_finalized: Option, + + /// Last epoch number for which safe/finalized were fetched. + pub(crate) last_tag_epoch: Option, + + /// If set, backfill from this block number before processing + /// subscription events. + pub(crate) backfill_from: Option, + + /// Max blocks per backfill batch. + pub(crate) backfill_batch_size: u64, + + /// Genesis timestamp, used for epoch calculation. + pub(crate) genesis_timestamp: u64, +} + +impl

core::fmt::Debug for RpcHostNotifier

{ + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RpcHostNotifier") + .field("buffer_len", &self.block_buffer.len()) + .field("buffer_capacity", &self.buffer_capacity) + .field("backfill_from", &self.backfill_from) + .finish_non_exhaustive() + } +} + +impl

RpcHostNotifier

+where + P: Provider + Clone, +{ + /// Current tip block number from the buffer. + fn tip(&self) -> Option { + self.block_buffer.back().map(|b| b.number()) + } + + /// Look up a block hash in the buffer by block number. + fn buffered_hash(&self, number: u64) -> Option { + self.block_buffer.iter().rev().find(|b| b.number() == number).map(|b| b.hash()) + } + + /// Fetch a single block with its receipts from the provider. + async fn fetch_block(&self, number: u64) -> Result { + let rpc_block = self + .provider + .get_block_by_number(number.into()) + .full() + .await? + .ok_or(RpcHostError::SubscriptionClosed)?; + + let rpc_receipts = + self.provider.get_block_receipts(number.into()).await?.unwrap_or_default(); + + // Convert RPC block to our RecoveredBlock type. + let hash = rpc_block.header.hash; + let block = rpc_block + .map_transactions(|tx| { + let recovered = tx.inner; + let signer = recovered.signer(); + let tx: TransactionSigned = recovered.into_inner().into(); + Recovered::new_unchecked(tx, signer) + }) + .into_consensus(); + let sealed_header = Sealed::new_unchecked(block.header, hash); + let block: RecoveredBlock = SealedBlock::new(sealed_header, block.body.transactions); + + // Convert RPC receipts to consensus receipts. + let receipts = + rpc_receipts.into_iter().map(|r| r.inner.into_primitives_receipt()).collect(); + + Ok(RpcBlock { block, receipts }) + } + + /// Fetch a range of blocks concurrently. + async fn fetch_range(&self, from: u64, to: u64) -> Result>, RpcHostError> { + let mut blocks = Vec::with_capacity((to - from + 1) as usize); + // Fetch sequentially for now; can be parallelized later with + // futures::stream::FuturesOrdered. + for number in from..=to { + let block = self.fetch_block(number).await?; + blocks.push(Arc::new(block)); + } + Ok(blocks) + } + + /// Derive the epoch number from a block timestamp. + const fn epoch_of(&self, timestamp: u64) -> u64 { + timestamp.saturating_sub(self.genesis_timestamp) / (SLOT_SECONDS * SLOTS_PER_EPOCH) + } + + /// Refresh safe/finalized block numbers if an epoch boundary was crossed. + async fn maybe_refresh_tags(&mut self, timestamp: u64) -> Result<(), RpcHostError> { + let epoch = self.epoch_of(timestamp); + if self.last_tag_epoch == Some(epoch) { + return Ok(()); + } + + let safe = self + .provider + .get_block_by_number(BlockNumberOrTag::Safe) + .await? + .map(|b| b.header().number()); + let finalized = self + .provider + .get_block_by_number(BlockNumberOrTag::Finalized) + .await? + .map(|b| b.header().number()); + + self.cached_safe = safe; + self.cached_finalized = finalized; + self.last_tag_epoch = Some(epoch); + + debug!(epoch, safe, finalized, "refreshed block tags at epoch boundary"); + Ok(()) + } + + /// Remove invalidated entries from the buffer on reorg, then add new + /// blocks. + fn update_buffer_reorg(&mut self, fork_number: u64, new_blocks: &[Arc]) { + // Remove all blocks at or above the fork point. + while self.block_buffer.back().is_some_and(|b| b.number() >= fork_number) { + self.block_buffer.pop_back(); + } + self.push_to_buffer(new_blocks); + } + + /// Push blocks to the buffer, evicting oldest if over capacity. + fn push_to_buffer(&mut self, blocks: &[Arc]) { + for block in blocks { + self.block_buffer.push_back(Arc::clone(block)); + if self.block_buffer.len() > self.buffer_capacity { + self.block_buffer.pop_front(); + } + } + } + + /// Find the fork point by walking backward through the buffer. + /// + /// Returns the block number where the chain diverged (the first block + /// that differs), or `None` if the fork is deeper than the buffer. + async fn find_fork_point(&self, new_header: &RpcHeader) -> Result, RpcHostError> { + // Walk the new chain backward from the new header's parent. + let mut check_hash = new_header.parent_hash(); + let mut check_number = new_header.number().saturating_sub(1); + + loop { + match self.buffered_hash(check_number) { + Some(buffered) if buffered == check_hash => { + // Found the common ancestor at check_number. + // The fork point is the next block (first divergence). + return Ok(Some(check_number + 1)); + } + Some(_) => { + // Mismatch — keep walking backward. + if check_number == 0 { + return Ok(None); + } + // Fetch the parent of this block on the new chain. + let parent = self + .provider + .get_block_by_number(check_number.into()) + .await? + .ok_or(RpcHostError::SubscriptionClosed)?; + check_hash = parent.header().parent_hash(); + check_number = check_number.saturating_sub(1); + } + None => { + // Beyond our buffer — can't determine fork point. + return Ok(None); + } + } + } + } + + /// Process a backfill batch if pending. + /// + /// Returns `Some(notification)` if a batch was emitted, `None` if no + /// backfill is pending. + async fn drain_backfill( + &mut self, + ) -> Option, RpcHostError>> { + let from = self.backfill_from?; + let tip = match self.provider.get_block_number().await { + Ok(n) => n, + Err(e) => return Some(Err(e.into())), + }; + + if from > tip { + self.backfill_from = None; + return None; + } + + let to = tip.min(from + self.backfill_batch_size - 1); + + let blocks = match self.fetch_range(from, to).await { + Ok(b) => b, + Err(e) => return Some(Err(e)), + }; + + self.push_to_buffer(&blocks); + + // Advance or clear backfill. + if to >= tip { + self.backfill_from = None; + } else { + self.backfill_from = Some(to + 1); + } + + // Refresh tags using the last block's timestamp. + if let Some(last) = blocks.last() + && let Err(e) = self.maybe_refresh_tags(last.block.timestamp()).await + { + return Some(Err(e)); + } + + let segment = Arc::new(RpcChainSegment::new(blocks)); + Some(Ok(HostNotification { + kind: HostNotificationKind::ChainCommitted { new: segment }, + safe_block_number: self.cached_safe, + finalized_block_number: self.cached_finalized, + })) + } + + /// Handle a new header from the subscription stream. + async fn handle_new_head( + &mut self, + header: RpcHeader, + ) -> Result, RpcHostError> { + let new_number = header.number(); + let new_parent = header.parent_hash(); + + // Check parent hash continuity. + let is_reorg = self.tip().is_some_and(|tip_num| { + self.buffered_hash(tip_num).is_some_and(|tip_hash| { + // Parent should point to our tip, and the new block + // should be exactly one ahead. + new_parent != tip_hash || new_number != tip_num + 1 + }) + }); + + let kind = if is_reorg { + self.handle_reorg(header).await? + } else { + self.handle_advance(header).await? + }; + + // Refresh block tags. + let timestamp = match &kind { + HostNotificationKind::ChainCommitted { new } => { + new.blocks_and_receipts().last().map(|bar| bar.block.timestamp()).unwrap_or(0) + } + HostNotificationKind::ChainReorged { new, .. } => { + new.blocks_and_receipts().last().map(|bar| bar.block.timestamp()).unwrap_or(0) + } + HostNotificationKind::ChainReverted { .. } => 0, + }; + if timestamp > 0 { + self.maybe_refresh_tags(timestamp).await?; + } + + Ok(HostNotification { + kind, + safe_block_number: self.cached_safe, + finalized_block_number: self.cached_finalized, + }) + } + + /// Handle a normal chain advance (no reorg). + async fn handle_advance( + &mut self, + header: RpcHeader, + ) -> Result, RpcHostError> { + let new_number = header.number(); + let from = self.tip().map_or(new_number, |t| t + 1); + + let blocks = self.fetch_range(from, new_number).await?; + self.push_to_buffer(&blocks); + + Ok(HostNotificationKind::ChainCommitted { new: Arc::new(RpcChainSegment::new(blocks)) }) + } + + /// Handle a reorg: find fork point, emit `ChainReorged`. + async fn handle_reorg( + &mut self, + header: RpcHeader, + ) -> Result, RpcHostError> { + let new_number = header.number(); + + let fork_number = self.find_fork_point(&header).await?.ok_or_else(|| { + let depth = self + .tip() + .unwrap_or(0) + .saturating_sub(self.block_buffer.front().map_or(0, |b| b.number())); + RpcHostError::ReorgTooDeep { depth, capacity: self.buffer_capacity } + })?; + + // Collect reverted blocks from the buffer before removing them. + let old_blocks: Vec> = self + .block_buffer + .iter() + .filter(|b| b.number() >= fork_number) + .map(Arc::clone) + .collect(); + + // Fetch new chain from fork point to new head. + let blocks = self.fetch_range(fork_number, new_number).await?; + self.update_buffer_reorg(fork_number, &blocks); + + Ok(HostNotificationKind::ChainReorged { + old: Arc::new(RpcChainSegment::new(old_blocks)), + new: Arc::new(RpcChainSegment::new(blocks)), + }) + } +} + +impl

HostNotifier for RpcHostNotifier

+where + P: Provider + Clone + Send + Sync + 'static, +{ + type Chain = RpcChainSegment; + type Error = RpcHostError; + + async fn next_notification( + &mut self, + ) -> Option, Self::Error>> { + // Drain pending backfill first. + if let Some(result) = self.drain_backfill().await { + return Some(result); + } + + // Await next header from subscription. + let header = self.header_sub.next().await?; + Some(self.handle_new_head(header).await) + } + + fn set_head(&mut self, block_number: u64) { + self.backfill_from = Some(block_number); + } + + fn set_backfill_thresholds(&mut self, max_blocks: Option) { + if let Some(max) = max_blocks { + self.backfill_batch_size = max; + } + } + + fn send_finished_height(&self, _block_number: u64) -> Result<(), Self::Error> { + // No-op: no ExEx to notify for an RPC follower. + Ok(()) + } +} diff --git a/crates/host-rpc/src/segment.rs b/crates/host-rpc/src/segment.rs new file mode 100644 index 0000000..5c76af5 --- /dev/null +++ b/crates/host-rpc/src/segment.rs @@ -0,0 +1,74 @@ +use alloy::consensus::{BlockHeader, ReceiptEnvelope}; +use signet_extract::{BlockAndReceipts, Extractable}; +use signet_types::primitives::RecoveredBlock; +use std::sync::Arc; + +/// A block with its receipts, fetched via RPC. +#[derive(Debug)] +pub struct RpcBlock { + /// The recovered block (with senders). + pub block: RecoveredBlock, + /// The receipts for this block's transactions. + pub receipts: Vec, +} + +impl RpcBlock { + /// The block number. + pub fn number(&self) -> u64 { + self.block.number() + } + + /// The block hash. + pub const fn hash(&self) -> alloy::primitives::B256 { + self.block.header.hash() + } + + /// The parent block hash. + pub fn parent_hash(&self) -> alloy::primitives::B256 { + self.block.parent_hash() + } +} + +/// A chain segment fetched via RPC. +/// +/// Contains one or more blocks with their receipts, ordered by block number +/// ascending. Blocks are wrapped in [`Arc`] for cheap sharing with the +/// notifier's internal buffer. +#[derive(Debug)] +pub struct RpcChainSegment { + blocks: Vec>, +} + +impl RpcChainSegment { + /// Create a new segment from a list of blocks. + pub const fn new(blocks: Vec>) -> Self { + Self { blocks } + } +} + +impl Extractable for RpcChainSegment { + type Block = RecoveredBlock; + type Receipt = ReceiptEnvelope; + + fn blocks_and_receipts( + &self, + ) -> impl Iterator> { + self.blocks.iter().map(|b| BlockAndReceipts { block: &b.block, receipts: &b.receipts }) + } + + fn first_number(&self) -> Option { + self.blocks.first().map(|b| b.number()) + } + + fn tip_number(&self) -> Option { + self.blocks.last().map(|b| b.number()) + } + + fn len(&self) -> usize { + self.blocks.len() + } + + fn is_empty(&self) -> bool { + self.blocks.is_empty() + } +} diff --git a/docs/superpowers/specs/2026-03-13-rpc-host-notifier-design.md b/docs/superpowers/specs/2026-03-13-rpc-host-notifier-design.md new file mode 100644 index 0000000..d344a91 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-rpc-host-notifier-design.md @@ -0,0 +1,386 @@ +# RPC Host Notifier: HostNotifier over Alloy RPC Provider + +**Date:** 2026-03-13 +**Status:** Draft + +## Prerequisite + +This spec builds on the **Host Context Adapter** design +(`docs/superpowers/specs/2026-03-13-host-context-adapter-design.md`) and its +implementation plan. That work introduces: + +- The `HostNotifier` trait and `HostNotification`/`HostNotificationKind` types + in `signet-node-types` +- The `signet-host-reth` crate (reth ExEx implementation) +- The refactor of `signet-node` to be generic over `HostNotifier` + +This spec assumes that work is complete. It modifies the trait types +introduced there and adds a second backend implementation. + +## Goal + +Implement `HostNotifier` backed by an alloy RPC provider over WebSocket, +enabling signet-node to follow a host chain without embedding a reth node. +This enables lightweight standalone deployment and multi-host flexibility +(any EL client with WS support). + +## Scope + +This design covers: + +1. **`signet-host-rpc`** — a new crate implementing `HostNotifier` over an + alloy WS provider +2. **Trait changes to `signet-node-types`** — modifying `HostNotificationKind` + to remove the outer `Arc` and replace reverted chain data with a lightweight + `RevertedRange` +3. **Trait changes to `signet-extract`** — replacing the tuple return in + `blocks_and_receipts` with a named struct, and `&Vec` with `&[R]` + +Out of scope: +- Alias oracle for the RPC backend (sync trait would need async redesign) +- Changes to `signet-host-reth` beyond adapting to the trait changes +- Transaction pool or blob cacher integration + +## Architecture + +``` +signet-extract signet-node-types signet-host-reth +(BlockAndReceipts struct) (RevertedRange, no Arc) (adapted to trait changes) + \ | / + \ | / + v v v + signet-node + (generic over HostNotifier) + ^ + | + signet-host-rpc + (alloy WS provider impl) +``` + +## Design + +### Trait Changes + +#### `signet-extract`: `BlockAndReceipts` struct + +The `Extractable` trait's `blocks_and_receipts` method currently returns +`impl Iterator)>`. This changes +to a named struct with a slice reference: + +```rust +/// A block with its associated receipts. +pub struct BlockAndReceipts<'a, B, R> { + /// The block. + pub block: &'a B, + /// The receipts for this block's transactions. + pub receipts: &'a [R], +} + +pub trait Extractable: Debug + Sync { + type Block: BlockHeader + HasTxns + Debug + Sync; + type Receipt: TxReceipt + Debug + Sync; + + fn blocks_and_receipts( + &self, + ) -> impl Iterator>; + + // provided methods unchanged +} +``` + +This affects the reth shim in `signet-blobber` and the `SealedBlock` impl — +both must return `BlockAndReceipts` instead of tuples. + +#### `signet-node-types`: `HostNotificationKind` changes + +Two changes: + +1. **Remove outer `Arc`** from chain variants. Each backend handles sharing + internally (reth wraps its chain in `Arc` inside `RethChain`; the RPC + backend uses `Arc` inside `RpcChainSegment`). + +2. **Replace reverted chain data with `RevertedRange`.** The RPC backend + cannot cheaply provide full reverted block data. Since `signet-node`'s + revert handling only uses block number ranges, this is sufficient. + +```rust +/// A range of reverted block numbers. +#[derive(Debug, Clone, Copy)] +pub struct RevertedRange { + /// The first (lowest) reverted block number. + pub first: u64, + /// The last (highest) reverted block number. + pub last: u64, +} + +pub enum HostNotificationKind { + /// A new chain segment was committed. + ChainCommitted { new: C }, + /// A chain segment was reverted. + ChainReverted { reverted: RevertedRange }, + /// A reorg: one segment was reverted, another committed. + ChainReorged { reverted: RevertedRange, new: C }, +} +``` + +Accessor methods: +- `committed_chain() -> Option<&C>` — returns `Some` for `ChainCommitted` + and `ChainReorged` +- `reverted_range() -> Option<&RevertedRange>` — returns `Some` for + `ChainReverted` and `ChainReorged` + +The reth backend populates `RevertedRange` from the reverted chain's +`first_number()` and `tip_number()` before discarding the full chain data. + +`signet-node`'s `on_host_revert` uses these values for: +- Early return when `reverted.last <= host_deploy_height` (revert + is entirely before the rollup's deployment) +- Determining the block range to revert in storage +- Logging the reverted range + +### `signet-host-rpc` Crate + +#### Chain Segment Types + +```rust +/// A block with its receipts, fetched via RPC. +#[derive(Debug)] +pub struct RpcBlock { + /// The sealed block. + pub block: SealedBlock, + /// The receipts for this block's transactions. + pub receipts: Vec, +} + +/// A chain segment fetched via RPC. +#[derive(Debug)] +pub struct RpcChainSegment { + /// Blocks and their receipts, ordered by block number ascending. + blocks: Vec>, +} +``` + +`RpcChainSegment` implements `Extractable` with O(1) overrides for +`first_number`, `tip_number`, `len`, and `is_empty`. The `Block` associated +type is `SealedBlock` (already satisfies `BlockHeader + +HasTxns`). The `Receipt` associated type should be whichever alloy receipt +type satisfies `TxReceipt` — likely `alloy::consensus::Receipt` +rather than the RPC wrapper `TransactionReceipt`. The exact type must be +verified during implementation; RPC responses will be converted at fetch +time. + +Blocks are wrapped in `Arc` to allow cheap sharing between the +notifier's internal buffer and emitted chain segments. + +#### `RpcHostNotifier` + +```rust +pub struct RpcHostNotifier

{ + /// The alloy provider (must support subscriptions). + provider: P, + + /// Subscription stream of new block headers. + header_sub: SubscriptionStream

, + + /// Recent blocks for reorg detection and cache. + block_buffer: VecDeque>, + + /// Maximum entries in the block buffer. + buffer_capacity: usize, + + /// Cached safe block number, refreshed at epoch boundaries. + cached_safe: Option, + + /// Cached finalized block number, refreshed at epoch boundaries. + cached_finalized: Option, + + /// Last epoch number for which safe/finalized were fetched. + last_tag_epoch: Option, + + /// If set, backfill from this block number before processing + /// subscription events. + backfill_from: Option, + + /// Max blocks per backfill batch. + backfill_batch_size: u64, +} +``` + +Generic over `P` with bounds requiring alloy provider + subscription +support. The exact trait bounds depend on alloy's trait hierarchy and will +be determined during implementation. + +##### `next_notification` Flow + +1. **Drain backfill.** If `backfill_from` is set, fetch a batch of blocks + (concurrently) from that point toward the current tip. Build an + `RpcChainSegment`, emit `ChainCommitted`. Advance `backfill_from`. + Repeat until backfill is drained. + +2. **Await subscription.** Wait for the next `newHeads` header. + +3. **Check parent hash continuity.** Compare the new block's `parent_hash` + against the hash of our tip block in `block_buffer`. + +4. **Normal case (parent matches).** Fetch the full block + receipts for + any blocks between our tip and the new head (handles small gaps from + missed subscription events). Build `RpcChainSegment`, emit + `ChainCommitted`. + +5. **Reorg case (parent mismatch).** Walk backward through `block_buffer` + to find the fork point — the highest block number where our buffered + hash matches the actual chain. If the fork point is not in the buffer, + return `RpcHostError::ReorgTooDeep`. Otherwise, build `RevertedRange` + from the fork point to our old tip, fetch the new blocks from the fork + point to the new head, and emit `ChainReorged`. + +6. **Update block tags.** Derive the epoch number from the block's + timestamp (`(timestamp - genesis_timestamp) / (12 * 32)`). If the epoch + changed since `last_tag_epoch`, fetch safe and finalized block numbers + via `eth_getBlockByNumber("safe")` and + `eth_getBlockByNumber("finalized")`. + +7. **Update buffer.** Push newly fetched blocks (as `Arc`) into + `block_buffer`. On reorg, remove invalidated entries first. Evict + oldest entries if over `buffer_capacity`. + +##### `set_head` + +Sets `backfill_from` to the given block number. The next call to +`next_notification` will drain the backfill before processing subscription +events. Blocks are fetched concurrently in batches of `backfill_batch_size`. + +##### `set_backfill_thresholds` + +Sets `backfill_batch_size`. + +##### `send_finished_height` + +No-op. There is no ExEx to notify. Returns `Ok(())`. + +#### Builder + +```rust +pub struct RpcHostNotifierBuilder

{ .. } + +impl

RpcHostNotifierBuilder

{ + pub fn new(provider: P) -> Self; + pub fn with_buffer_capacity(self, capacity: usize) -> Self; // default: 64 + pub fn with_backfill_batch_size(self, batch_size: u64) -> Self; // default: 32 + pub async fn build(self) -> Result, RpcHostError>; +} +``` + +`build()` is async — it establishes the `newHeads` WebSocket subscription. + +#### Error Type + +```rust +#[derive(Debug, thiserror::Error)] +pub enum RpcHostError { + /// The WebSocket subscription was dropped unexpectedly. + #[error("subscription closed")] + SubscriptionClosed, + + /// An RPC call failed. + #[error("rpc error: {0}")] + Rpc(#[from] alloy::transports::RpcError), + + /// Reorg deeper than the block buffer. + #[error("reorg depth {depth} exceeds buffer capacity {capacity}")] + ReorgTooDeep { depth: u64, capacity: usize }, +} +``` + +#### Dependencies + +```toml +[dependencies] +signet-node-types.workspace = true +signet-extract.workspace = true +signet-types.workspace = true + +alloy.workspace = true +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +``` + +No reth dependencies. + +### Adaptations to `signet-host-reth` + +The existing reth backend adapts to the trait changes: + +- `HostNotificationKind::ChainCommitted { new }` — pass `RethChain` + directly instead of `Arc`. `RethChain` already holds + `Arc>` internally. +- `HostNotificationKind::ChainReverted` — build `RevertedRange` from the + reverted chain's `first().number()` and `tip().number()`. +- `HostNotificationKind::ChainReorged` — same: `RevertedRange` from the + old chain, `RethChain` directly for the new chain. +- `Extractable` impl returns `BlockAndReceipts` struct instead of tuple. + +## What Does NOT Change + +- `signet-node` — generic over `HostNotifier` (per prerequisite refactor); + only adapts to `RevertedRange` and `BlockAndReceipts` changes +- `signet-blobber` — adapts `ExtractableChainShim` to return + `BlockAndReceipts` (mechanical) +- `signet-node-tests` — continue using reth test utilities via + `signet-host-reth` +- Alias oracle — out of scope; RPC deployments can use `HashSet

` + or a custom `AliasOracleFactory` + +## Testing Strategy + +- **Unit tests:** Mock provider that returns canned blocks and receipts + for subscription events, verifying normal advance, gap filling, reorg + detection, backfill, and epoch-boundary tag refresh +- **Reorg tests:** Construct sequences where parent hash mismatches trigger + reorg detection at various depths, including `ReorgTooDeep` +- **Integration tests:** Connect to a local dev node (anvil or similar) + with WS support to verify end-to-end subscription and block fetching + +## Implementation Notes + +**Receipt type verification.** The `Extractable` trait requires +`Receipt: TxReceipt`. Alloy's RPC `TransactionReceipt` wraps a +consensus receipt with additional metadata. The implementor should verify +which alloy type satisfies the bound and convert at fetch time if needed. + +**Subscription `Send` bound.** `RpcHostNotifier` will likely need to be +`Send` for `tokio::spawn`. The implementor should verify that alloy's +`SubscriptionStream
` with the WS transport is `Send`. + +**Backfill and subscription buffer.** Long backfills may cause the WS +subscription buffer to overflow. The gap-filling logic in step 4 of the +notification flow handles missed subscription events, so this is +self-healing. If the gap is larger than the block buffer, the notifier +should re-subscribe. + +**Epoch calculation is approximate.** The formula +`(timestamp - genesis_timestamp) / (12 * 32)` assumes aligned genesis and +exact 12s slots. This is sufficient for reducing RPC call frequency — an +off-by-one epoch is harmless. + +**Reconnection.** The spec does not define a reconnection strategy for +dropped WebSocket connections. `SubscriptionClosed` is surfaced to the +caller, who is responsible for reconstructing the notifier. A reconnection +wrapper could be added later. + +## Design Decisions + +| Decision | Rationale | +|----------|-----------| +| WebSocket required | Production chain follower needs low-latency push notifications; polling adds unacceptable delay | +| Generic over provider | Supports any alloy transport with pubsub; not locked to a specific WS implementation | +| Minimal reorg signal (`RevertedRange`) | Avoids expensive RPC fetches for reverted blocks; signet-node only needs block number ranges for revert handling | +| No outer `Arc` in `HostNotificationKind` | Backends handle sharing internally; avoids double-Arc for RPC backend | +| Individual block buffer (`VecDeque>`) | Simple reorg detection via hash lookup; shares blocks cheaply with emitted segments | +| Epoch-aware safe/finalized caching | Safe and finalized only advance at epoch boundaries (~6.4 min); avoids unnecessary RPC calls every block | +| Batched concurrent backfill | Parallel `eth_getBlockByNumber` + `eth_getBlockReceipts` calls for throughput; batch size is configurable | +| `send_finished_height` is a no-op | No ExEx feedback mechanism for a standalone RPC follower | +| Alias oracle out of scope | Requires async trait redesign; orthogonal to the notifier | +| Async builder | Subscription establishment is inherently async; clean API | +| `BlockAndReceipts` struct in `Extractable` | Named fields over tuple; `&[R]` over `&Vec` | From a526497bca14cbddbe99f4655855b07f39f15670 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 15:01:06 -0400 Subject: [PATCH 16/19] chore: bump SDK deps to 0.16.0-rc.14 Aligns workspace dependency versions with the feat/extractable-metadata branch, eliminating duplicate signet-types/signet-constants crate resolutions. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c781fb8..e9b9ebe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,15 +47,15 @@ signet-rpc = { version = "0.16.0-rc.7", path = "crates/rpc" } init4-bin-base = { version = "0.18.0-rc.8", features = ["alloy"] } -signet-bundle = "0.16.0-rc.11" -signet-constants = "0.16.0-rc.11" -signet-evm = "0.16.0-rc.11" -signet-extract = "0.16.0-rc.11" -signet-test-utils = "0.16.0-rc.11" -signet-tx-cache = "0.16.0-rc.11" -signet-types = "0.16.0-rc.11" -signet-zenith = "0.16.0-rc.11" -signet-journal = "0.16.0-rc.11" +signet-bundle = "0.16.0-rc.14" +signet-constants = "0.16.0-rc.14" +signet-evm = "0.16.0-rc.14" +signet-extract = "0.16.0-rc.14" +signet-test-utils = "0.16.0-rc.14" +signet-tx-cache = "0.16.0-rc.14" +signet-types = "0.16.0-rc.14" +signet-zenith = "0.16.0-rc.14" +signet-journal = "0.16.0-rc.14" signet-storage = "0.6.4" signet-cold = "0.6.4" signet-hot = "0.6.4" From 4c84379148558386d40b0bd7985eeacff16ba83a Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 13:51:10 -0400 Subject: [PATCH 17/19] docs: add async AliasOracle implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-13-async-alias-oracle.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-13-async-alias-oracle.md diff --git a/docs/superpowers/plans/2026-03-13-async-alias-oracle.md b/docs/superpowers/plans/2026-03-13-async-alias-oracle.md new file mode 100644 index 0000000..26dc395 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-async-alias-oracle.md @@ -0,0 +1,139 @@ +# Async AliasOracle Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `AliasOracle::should_alias` async using RPITIT to support future async implementations. + +**Architecture:** Change the `AliasOracle` trait's `should_alias` method to return `impl Future> + Send`. Existing sync implementations wrap their bodies in `async move { ... }`. The single call site in `processor.rs` adds `.await`. + +**Tech Stack:** Rust RPITIT (stable since 1.75), `core::future::Future` + +--- + +## Chunk 1: Implementation + +### Task 1: Update all AliasOracle impls and call site + +All changes are made together in a single commit so no intermediate state breaks compilation. + +**Files:** +- Modify: `crates/block-processor/src/alias.rs:1-14` +- Modify: `crates/host-reth/src/alias.rs:1-42` +- Modify: `crates/block-processor/src/v1/processor.rs:106-109,188-195` + +- [ ] **Step 1: Update the trait definition and HashSet impl** + +In `crates/block-processor/src/alias.rs`, add `Future` import and make `should_alias` return an async future: + +```rust +use alloy::primitives::{Address, map::HashSet}; +use core::future::Future; +use std::sync::{Arc, Mutex}; + +/// Simple trait to allow checking if an address should be aliased. +pub trait AliasOracle { + /// Returns true if the given address is an alias. + fn should_alias(&self, address: Address) -> impl Future> + Send; +} + +impl AliasOracle for HashSet
{ + fn should_alias(&self, address: Address) -> impl Future> + Send { + let result = Ok(self.contains(&address)); + async move { result } + } +} +``` + +Note: the `HashSet` impl computes the result synchronously _before_ the async block, so `&self` is not captured across the await point. This avoids requiring `Self: Sync`. + +- [ ] **Step 2: Update RethAliasOracle impl** + +In `crates/host-reth/src/alias.rs`, add `use core::future::Future;` to imports, then change the impl: + +```rust +impl AliasOracle for RethAliasOracle { + fn should_alias(&self, address: Address) -> impl Future> + Send { + // Compute synchronously, then wrap in async. + let result = (|| { + // No account at this address. + let Some(acct) = self.0.basic_account(&address)? else { return Ok(false) }; + // Get the bytecode hash for this account. + let bch = match acct.bytecode_hash { + Some(hash) => hash, + // No bytecode hash; not a contract. + None => return Ok(false), + }; + // No code at this address. + if bch == KECCAK_EMPTY { + return Ok(false); + } + // Fetch the code associated with this bytecode hash. + let code = self + .0 + .bytecode_by_hash(&bch)? + .ok_or_eyre("code not found. This indicates a corrupted database")?; + + // If not a 7702 delegation contract, alias it. + Ok(!code.is_eip7702()) + })(); + async move { result } + } +} +``` + +- [ ] **Step 3: Update processor call site** + +In `crates/block-processor/src/v1/processor.rs`, update the helper method (line 106-109): + +```rust +/// Check if the given address should be aliased. +async fn should_alias(&self, address: Address) -> eyre::Result { + self.alias_oracle.create()?.should_alias(address).await +} +``` + +Update the loop in `run_evm` (line 192): + +```rust +if !to_alias.contains(&addr) && self.should_alias(addr).await? { +``` + +- [ ] **Step 4: Format** + +Run: `cargo +nightly fmt` + +- [ ] **Step 5: Lint all affected crates** + +Run: `cargo clippy -p signet-block-processor --all-features --all-targets` +Run: `cargo clippy -p signet-host-reth --all-features --all-targets` +Expected: PASS + +- [ ] **Step 6: Run tests** + +Run: `cargo t -p signet-block-processor` +Run: `cargo t -p signet-host-reth` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add crates/block-processor/src/alias.rs crates/host-reth/src/alias.rs crates/block-processor/src/v1/processor.rs +git commit -m "refactor: make AliasOracle::should_alias async via RPITIT" +``` + +### Task 2: Full workspace validation + +- [ ] **Step 1: Lint downstream crates** + +Run: `cargo clippy -p signet-node --all-features --all-targets` +Run: `cargo clippy -p signet-node-tests --all-features --all-targets` +Expected: PASS (bounds are on `AliasOracleFactory`, not `AliasOracle` directly) + +- [ ] **Step 2: Run node-tests** + +Run: `cargo t -p signet-node-tests` +Expected: PASS + +- [ ] **Step 3: Final format check** + +Run: `cargo +nightly fmt` From 684a759d2a5ed3ab5de9df9c97137fa279d26558 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 13:56:47 -0400 Subject: [PATCH 18/19] refactor: make AliasOracle::should_alias async via RPITIT Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/block-processor/src/alias.rs | 8 ++-- crates/block-processor/src/v1/processor.rs | 6 +-- crates/host-reth/src/alias.rs | 45 ++++++++++++---------- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/crates/block-processor/src/alias.rs b/crates/block-processor/src/alias.rs index 04e79cd..61245af 100644 --- a/crates/block-processor/src/alias.rs +++ b/crates/block-processor/src/alias.rs @@ -1,15 +1,17 @@ use alloy::primitives::{Address, map::HashSet}; +use core::future::Future; use std::sync::{Arc, Mutex}; /// Simple trait to allow checking if an address should be aliased. pub trait AliasOracle { /// Returns true if the given address is an alias. - fn should_alias(&self, address: Address) -> eyre::Result; + fn should_alias(&self, address: Address) -> impl Future> + Send; } impl AliasOracle for HashSet
{ - fn should_alias(&self, address: Address) -> eyre::Result { - Ok(self.contains(&address)) + fn should_alias(&self, address: Address) -> impl Future> + Send { + let result = Ok(self.contains(&address)); + async move { result } } } diff --git a/crates/block-processor/src/v1/processor.rs b/crates/block-processor/src/v1/processor.rs index d57fa68..9327874 100644 --- a/crates/block-processor/src/v1/processor.rs +++ b/crates/block-processor/src/v1/processor.rs @@ -104,8 +104,8 @@ where } /// Check if the given address should be aliased. - fn should_alias(&self, address: Address) -> eyre::Result { - self.alias_oracle.create()?.should_alias(address) + async fn should_alias(&self, address: Address) -> eyre::Result { + self.alias_oracle.create()?.should_alias(address).await } /// Process a single extracted block, returning an [`ExecutedBlock`]. @@ -189,7 +189,7 @@ where let mut to_alias: HashSet
= Default::default(); for transact in block_extracts.transacts() { let addr = transact.host_sender(); - if !to_alias.contains(&addr) && self.should_alias(addr)? { + if !to_alias.contains(&addr) && self.should_alias(addr).await? { to_alias.insert(addr); } } diff --git a/crates/host-reth/src/alias.rs b/crates/host-reth/src/alias.rs index 8a06344..8934f61 100644 --- a/crates/host-reth/src/alias.rs +++ b/crates/host-reth/src/alias.rs @@ -1,5 +1,5 @@ use alloy::{consensus::constants::KECCAK_EMPTY, primitives::Address}; -use core::fmt; +use core::{fmt, future::Future}; use eyre::OptionExt; use reth::providers::{StateProviderBox, StateProviderFactory}; use signet_block_processor::{AliasOracle, AliasOracleFactory}; @@ -17,27 +17,30 @@ impl fmt::Debug for RethAliasOracle { } impl AliasOracle for RethAliasOracle { - fn should_alias(&self, address: Address) -> eyre::Result { - // No account at this address. - let Some(acct) = self.0.basic_account(&address)? else { return Ok(false) }; - // Get the bytecode hash for this account. - let bch = match acct.bytecode_hash { - Some(hash) => hash, - // No bytecode hash; not a contract. - None => return Ok(false), - }; - // No code at this address. - if bch == KECCAK_EMPTY { - return Ok(false); - } - // Fetch the code associated with this bytecode hash. - let code = self - .0 - .bytecode_by_hash(&bch)? - .ok_or_eyre("code not found. This indicates a corrupted database")?; + fn should_alias(&self, address: Address) -> impl Future> + Send { + let result = (|| { + // No account at this address. + let Some(acct) = self.0.basic_account(&address)? else { return Ok(false) }; + // Get the bytecode hash for this account. + let bch = match acct.bytecode_hash { + Some(hash) => hash, + // No bytecode hash; not a contract. + None => return Ok(false), + }; + // No code at this address. + if bch == KECCAK_EMPTY { + return Ok(false); + } + // Fetch the code associated with this bytecode hash. + let code = self + .0 + .bytecode_by_hash(&bch)? + .ok_or_eyre("code not found. This indicates a corrupted database")?; - // If not a 7702 delegation contract, alias it. - Ok(!code.is_eip7702()) + // If not a 7702 delegation contract, alias it. + Ok(!code.is_eip7702()) + })(); + async move { result } } } From 47509767d6bf857ab429fa4343dacedaabcbf3f4 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 13 Mar 2026 15:24:41 -0400 Subject: [PATCH 19/19] fix: address PR #100 review feedback Add MissingBlock error variant for correct semantics, tighten RpcBlock field visibility, guard backfill_batch_size against zero, and document RPC reorg detection limitations and ChainReverted non-emission. Co-Authored-By: Claude Opus 4.6 --- crates/host-reth/src/alias.rs | 2 ++ crates/host-rpc/src/error.rs | 4 ++++ crates/host-rpc/src/notifier.rs | 20 +++++++++++++++++--- crates/host-rpc/src/segment.rs | 4 ++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/host-reth/src/alias.rs b/crates/host-reth/src/alias.rs index 8934f61..4169f4c 100644 --- a/crates/host-reth/src/alias.rs +++ b/crates/host-reth/src/alias.rs @@ -18,6 +18,8 @@ impl fmt::Debug for RethAliasOracle { impl AliasOracle for RethAliasOracle { fn should_alias(&self, address: Address) -> impl Future> + Send { + // Sync DB calls run inline on the async runtime. This matches reth's + // existing pattern of synchronous database access on async tasks. let result = (|| { // No account at this address. let Some(acct) = self.0.basic_account(&address)? else { return Ok(false) }; diff --git a/crates/host-rpc/src/error.rs b/crates/host-rpc/src/error.rs index bdfec83..012e69c 100644 --- a/crates/host-rpc/src/error.rs +++ b/crates/host-rpc/src/error.rs @@ -11,6 +11,10 @@ pub enum RpcHostError { #[error("rpc error: {0}")] Rpc(#[from] RpcError), + /// The RPC node returned no block for the requested number. + #[error("missing block {0}")] + MissingBlock(u64), + /// Reorg deeper than the block buffer. #[error("reorg depth {depth} exceeds buffer capacity {capacity}")] ReorgTooDeep { diff --git a/crates/host-rpc/src/notifier.rs b/crates/host-rpc/src/notifier.rs index 49dd495..9f0a603 100644 --- a/crates/host-rpc/src/notifier.rs +++ b/crates/host-rpc/src/notifier.rs @@ -91,7 +91,7 @@ where .get_block_by_number(number.into()) .full() .await? - .ok_or(RpcHostError::SubscriptionClosed)?; + .ok_or(RpcHostError::MissingBlock(number))?; let rpc_receipts = self.provider.get_block_receipts(number.into()).await?.unwrap_or_default(); @@ -183,6 +183,11 @@ where /// /// Returns the block number where the chain diverged (the first block /// that differs), or `None` if the fork is deeper than the buffer. + /// + /// **Limitation:** This queries the RPC node for blocks on the new chain. + /// If the node hasn't fully switched to the new chain, it may return + /// stale blocks from the old chain, producing an incorrect fork point. + /// This is an inherent limitation of RPC-based reorg detection. async fn find_fork_point(&self, new_header: &RpcHeader) -> Result, RpcHostError> { // Walk the new chain backward from the new header's parent. let mut check_hash = new_header.parent_hash(); @@ -205,7 +210,7 @@ where .provider .get_block_by_number(check_number.into()) .await? - .ok_or(RpcHostError::SubscriptionClosed)?; + .ok_or(RpcHostError::MissingBlock(check_number))?; check_hash = parent.header().parent_hash(); check_number = check_number.saturating_sub(1); } @@ -358,6 +363,15 @@ where } } +/// [`HostNotifier`] implementation for [`RpcHostNotifier`]. +/// +/// Note: this implementation never emits +/// [`HostNotificationKind::ChainReverted`]. The `newHeads` WebSocket +/// subscription only fires when a new block appears — a pure revert +/// (blocks removed without a replacement chain) produces no new header and +/// is therefore invisible to the subscription. Only +/// [`HostNotificationKind::ChainCommitted`] and +/// [`HostNotificationKind::ChainReorged`] are produced. impl

HostNotifier for RpcHostNotifier

where P: Provider + Clone + Send + Sync + 'static, @@ -384,7 +398,7 @@ where fn set_backfill_thresholds(&mut self, max_blocks: Option) { if let Some(max) = max_blocks { - self.backfill_batch_size = max; + self.backfill_batch_size = max.max(1); } } diff --git a/crates/host-rpc/src/segment.rs b/crates/host-rpc/src/segment.rs index 5c76af5..460a84a 100644 --- a/crates/host-rpc/src/segment.rs +++ b/crates/host-rpc/src/segment.rs @@ -7,9 +7,9 @@ use std::sync::Arc; #[derive(Debug)] pub struct RpcBlock { /// The recovered block (with senders). - pub block: RecoveredBlock, + pub(crate) block: RecoveredBlock, /// The receipts for this block's transactions. - pub receipts: Vec, + pub(crate) receipts: Vec, } impl RpcBlock {