Skip to content

feat(rpc): implement morph_getTransactionHashesByReference#106

Open
panos-xyz wants to merge 12 commits intomainfrom
feature/morph-reference-rpc
Open

feat(rpc): implement morph_getTransactionHashesByReference#106
panos-xyz wants to merge 12 commits intomainfrom
feature/morph-reference-rpc

Conversation

@panos-xyz
Copy link
Copy Markdown
Contributor

@panos-xyz panos-xyz commented Apr 30, 2026

Summary

  • Add morph-reference-index crate: persistent MDBX index (4 tables) for querying MorphTx transactions by their 32-byte reference key
  • Implement morph_getTransactionHashesByReference RPC with geth-compatible response shape and hex-quantity pagination
  • Wire startup backfill + reconcile (Task A) and live ExEx incremental updates (Task B) with correct coordination

Design

See docs/superpowers/specs/2026-04-29-morph-rpc-extensions-design.md for the full spec (7 rounds of adversarial review + multiple GPT reviews before implementation).

Storage: independent MDBX instance at <datadir>/morph/reference_index/, four tables:

  • ReferenceIndex — primary query index: (reference, blockNumber, txIndex, txHash) → blockTimestamp
  • BlockReferenceIndex — reverse index for atomic block deletion: (blockNumber, txIndex, txHash) → reference
  • IndexedBlocks — canonical hash per indexed block, for offline reorg detection
  • IndexMeta — chain identity, backfill state, jade_first_block_number, snapshot metadata

Startup (Task A): paired-snapshot validation → jade sentinel re-resolution → batched backfill from jade_first_block_number (256 blocks/batch, 10ms sleep, atomic last-batch) → startup reconcile (canonical hash check + suffix gap fill) → is_ready = true → initial FinishedHeight

Live (Task B / ExEx): registered via install_exex, drains from node launch to avoid backpressure, gates writes on is_ready, gap-fills on first ready notification, handles ChainCommitted/ChainReverted/ChainReorged atomically

RPC: -32000 "reference index initializing" while not ready; -32000 "reference index is behind" when canonical_tip - indexed_to > lag_threshold (default 16); accepts geth hex-quantity offset/limit; #[method(blocking)] for MDBX reads

Correctness fixes addressed during adversarial review (3 Codex rounds)

  • TransactionVariant::WithHash (not NoHash) for all historical block reads — reth docs say NoHash produces invalid tx hashes; index keys embed tx_hash
  • reconcile scans only from indexed_from as lower bound, and skips IndexedBlocks = None (never-indexed blocks are not a mismatch)
  • InProgress + SENTINEL crash recovery re-resolves Jade against current head before marking complete
  • First-ready ChainReverted with parent > indexed_to: fills the surviving canonical gap before the revert
  • fill_gap_idempotent (delete-then-write) replaces plain append for all gap-fill paths
  • send_finished_height_monotonic keeps FinishedHeight non-regressing across startup watch vs live notification race
  • Task A startup failure propagates as panic! into spawn_critical → node shutdown (not silent error!)
  • Paired snapshot snapshot_block_hash validated against main DB provider after node start
  • alloy_serde::quantity::opt for offset/limit to accept geth hex wire format
  • Internal DB/provider errors mapped to -32603 without leaking storage details to RPC clients

Test plan

  • cargo nextest run -p morph-reference-index — 17 unit tests (key encoding, DB open/chain identity, writer idempotency, reader lag/initializing guards)
  • cargo nextest run -p morph-node --features test-utils -E 'test(reference_index)' — 3 integration tests: single tx lookup, pagination across blocks, empty result for unrelated reference
  • cargo clippy -p morph-reference-index -p morph-rpc -p morph-node --all-targets -- -D warnings
  • cargo fmt --all -- --check
  • Manual node startup smoke test (reference index opens, backfill runs, RPC responds after ready)

Summary by CodeRabbit

  • New Features
    • Node now includes a persistent transaction reference index and exposes a new morph RPC for querying transactions by reference with pagination and lag protection.
  • API
    • RPC crate publicly exposes the morph namespace and the new reference-query RPC method.
  • Tests
    • Added integration tests covering reference-indexing and reference-query behavior.

…age layer

- 4-table schema: ReferenceIndex, BlockReferenceIndex, IndexedBlocks, IndexMeta
- chain identity validation on DB open (chain_id, genesis_hash, schema_version)
- BackfillState (NotStarted/InProgress/Complete) + is_ready AtomicBool
- writer: write_block / delete_block (three-table atomic)
- reader: prefix cursor query with is_ready + lag threshold guards
- backfill: jade_first_block binary search + batched backfill with crash recovery
- reconcile: canonical hash check (offline reorg detection) + suffix gap fill
- metrics helpers for lag, progress, state, readiness
- 17 unit tests all passing
Implements the RPC namespace, ExEx, and node integration that back the new
reference index storage layer added in the previous commit.

- morph-rpc: new morph_ namespace with getTransactionHashesByReference
  - returns -32000 "reference index initializing" before Task A completes
  - returns -32000 "reference index is behind" when lag exceeds threshold
  - validates limit/offset bounds with -32602
- morph-node: ReferenceIndexControl (watch-channel-coordinated) wires the
  startup indexing task (Task A) with the ExEx (Task B)
  - Task A: maybe_reset_jade_sentinel -> backfill -> reconcile ->
    is_ready=true -> startup FinishedHeight
  - Task B: drains notifications from node launch, gates writes on is_ready,
    gap-fills from main DB on first is_ready notification, processes
    ChainCommitted/Reverted/Reorged with three-table atomic writes, sends
    FinishedHeight(BlockNumHash)
- MorphAddOns: optional reference_index control; when present spawns Task A
  via task_executor and registers the morph_ RPC handler
- bin/morph-reth: opens reference index DB under <datadir>/morph/reference_index,
  installs the ExEx, and injects the control into MorphAddOns
Three node-level integration tests verify the reference index storage layer:
- finds single morph_tx by reference after backfill
- paginates results across multiple blocks correctly
- returns empty for unrelated reference keys

Tests run backfill + reconcile directly against the node's provider
(no ExEx required), which is sufficient to validate the query path.
Finding 4 (Critical): NoHash → WithHash for all historical block reads
  - backfill.rs, reconcile.rs, exex/reference_index.rs fill_gap
  - reth docs say TransactionVariant::NoHash produces invalid tx hashes;
    write_block stores tx_hash in index keys, so NoHash caused silent data
    corruption in all historical indexing paths

Finding 3 (Moderate): reconcile check_start clamped to indexed_from
  - sentinel path (jade not yet active): backfill sets indexed_to=head but
    writes no IndexedBlocks, so reconcile's scan would see None != canonical
    and trigger spurious fork_height at check_start
  - fix: take max(indexed_from, indexed_to - depth) as check_start

Finding 1 (Critical): Task A startup failure upgraded to fatal
  - was: error! log and continue, leaving RPC stuck at "initializing" forever
  - now: panic propagated into spawn_critical → node shutdown on failure

Finding 5 (Moderate): first-ready gap fill is now delete-then-write
  - fill_gap renamed to fill_gap_idempotent, deletes stale entries first
  - also handles ChainReverted notification as first is_ready notification

Finding 8 (Nit): RPC method marked blocking for MDBX I/O
  - #[method(name = ..., blocking)] so jsonrpsee dispatches on blocking pool

Also: clarify write_block doc — not idempotent, caller must delete_block first
Finding F2 (High): InProgress resume clamped to jade_first_block_number
  - If crash happens between InProgress write and first batch commit,
    indexed_to is still 0; resume now uses max(indexed_to+1, jade_first)
    instead of plain indexed_to+1 to avoid re-indexing pre-Jade blocks

Finding F3 (High): RPC offset/limit use alloy_serde::quantity::opt
  - geth sends hex-encoded quantities ("0x0", "0x64") on the wire;
    plain u64 serde deserializer rejected them silently
  - matches spec constraint #2: geth compatibility first

Finding F5 (Medium): paired snapshot validation implemented
  - ReferenceIndexDb::validate_paired_snapshot(main_block_hash_fn)
    checks snapshot_block_hash/number against the main DB provider
    and returns descriptive errors on mismatch or ahead-of-DB cases
  - called from run_startup_indexing before backfill starts, after
    provider is available (matching spec timing requirement)
…arent

Scenario:
  - reconcile finishes with indexed_to = H
  - is_ready=false drain window: ExEx drains ChainCommitted{H+1}, {H+2}
    but writes nothing
  - is_ready=true, first_ready=true
  - first notification is ChainReverted { old: [H+2] }
  - revert_start = H+2, parent = H+1

Old code:
  first_ready branch only filled when revert_start <= indexed_to (false here),
  so nothing happened. handle_notification then ran delete_block(H+2) as a
  no-op and set indexed_to = parent = H+1 -- but H+1 had never been written.
  Result: indexed_to is ahead of what is actually indexed, future reconcile
  sees phantom hash mismatch on H+1.

Fix:
  In first_ready ChainReverted branch, compute parent = revert_start - 1.
  If parent > indexed_to, drain window committed canonical blocks
  (indexed_to+1..=parent) that will SURVIVE this revert; backfill them now
  via fill_gap_idempotent. handle_notification then rolls back to parent
  consistently.

  parent <= indexed_to case (revert overlaps already-indexed range) is
  already correct without special handling.
…inishedHeight

Finding 1 (High): reconcile treated indexed_hash=None as hash mismatch
  The sentinel path (Jade not yet activated) sets indexed_to=head but writes
  no IndexedBlocks entries.  reconcile then scanned [indexed_from, indexed_to],
  found None != canonical_hash, and falsely triggered reorg rebuild for
  pre-Jade blocks.  Fix: skip blocks where IndexedBlocks has no entry (None
  means "never indexed", not "hash mismatch").

  Note: indexed_from.max(depth_start) from an earlier round correctly narrows
  the scan range but did NOT prevent the None comparison since the range still
  included indexed_to itself in the sentinel case.

Finding 2 (Medium): startup FinishedHeight could regress past live commits
  tokio::select! is non-deterministic: if a ChainCommitted notification is
  processed before the startup watch channel fires, ExExEvent::FinishedHeight
  would be sent at H+k then regressed to H (the startup indexed_to).
  Fix: introduce send_finished_height_monotonic() that only sends when the
  new height strictly exceeds last_finished; both the startup watch arm and
  ChainCommitted/ChainReorged share this helper.  ChainReorged is the one
  permitted exception (reth ExEx docs allow height to go down on reorgs).
…nal error scrub

Finding 1 (Critical): InProgress + sentinel crash-recovery skips Jade history
  If node crashes between writing InProgress+SENTINEL (txn 1) and writing
  Complete (txn 2), the next restart had BackfillState::InProgress and
  jade_first_block_number=SENTINEL.  The previous fix for InProgress resume
  only avoided starting from block 1, but the sentinel fast-path inside the
  InProgress branch still blindly marked Complete without re-resolving Jade.

  Fix: when InProgress+SENTINEL, re-run resolve_jade_first_block against the
  current head.  If Jade is now active, persist the real jade_first_block_number
  and continue backfill from there.  Only take the immediate-complete shortcut
  when Jade is still not active on the current head.

Finding 2 (Moderate): to_rpc_error leaked internal Database/Provider/Other
  error strings verbatim to RPC callers.  The spec only documents two
  state-gating responses; anything else is an internal failure.
  Fix: map Database/Provider/Other to a fixed -32603 "internal reference
  index error" message; log the full error internally with tracing::error!.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

Warning

Rate limit exceeded

@panos-xyz has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 33 minutes and 7 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 80d8d52b-9074-45dc-b508-412b9c4553c6

📥 Commits

Reviewing files that changed from the base of the PR and between b6aa870 and eb42e6c.

📒 Files selected for processing (2)
  • crates/reference-index/src/db.rs
  • crates/reference-index/src/reader.rs
📝 Walkthrough

Walkthrough

A new morph-reference-index crate implements a persistent transaction reference index (DB, writer/reader, backfill, reconcile, ExEx) integrated into node startup and exposed via a new morph_ JSON-RPC namespace for querying transaction hashes by reference.

Changes

Reference Index Feature & Integration

Layer / File(s) Summary
Workspace / Manifest
Cargo.toml, bin/morph-reth/Cargo.toml, crates/node/Cargo.toml, crates/rpc/Cargo.toml, crates/reference-index/Cargo.toml
Adds new workspace member crates/reference-index, workspace alias morph-reference-index, and several git reth-* workspace deps; crates updated to depend on morph-reference-index.
Crate Surface
crates/reference-index/src/lib.rs
New crate entrypoint; public modules exported and re-exports ReferenceIndexDb, ReferenceIndexReader, types, constants, and defaults.
Data Schema & Types
crates/reference-index/src/tables.rs, crates/reference-index/src/types.rs
Defines DB table schemas and fixed-length key/value encodings; adds BackfillState, ReferenceQuery, ReferenceTransactionResult, SCHEMA_VERSION and error types.
Storage Layer
crates/reference-index/src/db.rs
Adds ReferenceIndexDb wrapper around MDBX, chain-identity validation on open, readiness flag, metadata read helpers, and tests for initialization and codec roundtrips.
Write Path
crates/reference-index/src/writer.rs
Writes/deletes block-scoped reference entries, and metadata writer helpers (indexed_from/to, backfill state/current, jade sentinel). Includes unit tests.
Backfill & Reconcile
crates/reference-index/src/backfill.rs, crates/reference-index/src/reconcile.rs
Backfill with Jade-first-block detection and checkpointing; startup reconcile that detects and rolls back reorgs and fills gaps up to head.
Reader & Query Logic
crates/reference-index/src/reader.rs
ReferenceIndexReader enforces readiness and lag threshold, opens read tx, seeks by composite key, paginates and returns ReferenceTransactionResult.
Node Integration (ExEx & Add-Ons)
crates/node/src/exex/mod.rs, crates/node/src/exex/reference_index.rs, crates/node/src/add_ons.rs, crates/node/src/lib.rs, bin/morph-reth/src/main.rs
Pre-opens reference DB at startup producing ReferenceIndexControl + startup receiver; MorphAddOns gains optional reference_index control and spawns blocking startup indexing; new ExEx reference_index_exex processes notifications and applies transactional updates; run_startup_indexing implemented.
RPC Surface & Handler
crates/rpc/src/lib.rs, crates/rpc/src/morph/mod.rs, crates/rpc/src/morph/rpc.rs, crates/rpc/src/morph/handler.rs
Adds morph_ JSON-RPC namespace with getTransactionHashesByReference (ReferenceQueryArgs DTO); MorphRpcHandler wires ReferenceIndexReader + provider, maps ReferenceIndexError → JSON-RPC errors.
Integration Tests
crates/node/tests/it/main.rs, crates/node/tests/it/reference_index.rs
New Tokio integration tests exercising injection of MorphTx with references, running backfill/reconcile/startup, querying ReferenceIndexReader and asserting pagination, ordering, and empty-result cases.

Sequence Diagram(s)

sequenceDiagram
    participant NodeBin as Node Binary
    participant RefDB as ReferenceIndexDB
    participant NodeBuilder as Node Builder / AddOns
    participant Startup as Startup Indexing Task
    participant ExEx as ExEx Task
    participant Chain as Blockchain Provider

    NodeBin->>RefDB: open(chain_id, genesis_hash)
    RefDB->>RefDB: validate_or_init_chain_identity()
    NodeBin->>NodeBuilder: with_reference_index(control)
    NodeBuilder->>Startup: spawn_blocking(run_startup_indexing)
    Startup->>Chain: read headers / resolve Jade first block
    Chain-->>Startup: headers / jade_block
    Startup->>RefDB: run_backfill(batch writes)
    RefDB->>RefDB: write indexed blocks & meta
    Startup->>RefDB: run_startup_reconcile()
    RefDB->>Chain: verify paired snapshots / detect reorg
    RefDB->>RefDB: fill gaps / update indexed_to
    Startup->>RefDB: mark_startup_finished() -> signal
    ExEx->>RefDB: await readiness signal
    ExEx->>Chain: receive block notifications
    ExEx->>RefDB: apply transactional updates per notification
Loading
sequenceDiagram
    participant Client as JSON-RPC Client
    participant Rpc as Morph RPC Handler
    participant Reader as ReferenceIndexReader
    participant RefDB as ReferenceIndexDB

    Client->>Rpc: morph_getTransactionHashesByReference(args)
    Rpc->>Reader: build ReferenceQuery + canonical_tip
    Reader->>RefDB: is_ready()?
    alt not ready
        RefDB-->>Reader: not ready
        Reader-->>Rpc: IndexNotInitialized error
        Rpc-->>Client: JSON-RPC error
    else ready
        Reader->>RefDB: indexed_to (compute lag)
        alt lag > threshold
            Reader-->>Rpc: IndexBehind error
            Rpc-->>Client: JSON-RPC error
        else
            Reader->>RefDB: open read tx, seek reference key
            Reader->>RefDB: iterate, apply offset/limit
            Reader-->>Rpc: Vec<ReferenceTransactionResult>
            Rpc-->>Client: JSON-RPC result
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • chengwenxi
  • anylots

Poem

🐰 I dug a little index under chain-lit logs,
Wrote hashes, fixed gaps, and chased Jade frogs.
Startup hummed, ExEx danced, RPCs now find,
Transactions by reference — neat and kind.
Hooray — a hop, a commit, and a carrot-lined mind!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly describes the primary change: implementing a new RPC method morph_getTransactionHashesByReference. It directly corresponds to the main feature being added.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/morph-reference-rpc

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/node/src/exex/reference_index.rs`:
- Around line 95-104: The startup branch forwards a potentially stale
FinishedHeight (startup_rx) that can be rendered non-canonical by an intervening
ChainReorged; before calling send_finished_height_monotonic(&ctx.events, block,
&mut last_finished) re-validate the startup payload against the current index
state and canonical hash (e.g. compare block.number and block.hash to
control.db.indexed_to() and the chain's canonical hash) and skip/dismiss the
startup payload if any notification (ChainReorged or other index mutations) has
already advanced or changed the canonical head; apply the same guard to the
other occurrence around send_finished_height_monotonic noted in the review
(lines referenced 359-370) to avoid re-advancing pruning to a non-canonical
block.

In `@crates/reference-index/src/db.rs`:
- Around line 169-181: The code currently allows reopening when
IndexMeta::SchemaVersion is missing (stored_schema == None), which lets a
pre-schema DB be opened as if current; update the reopen logic to reject a
missing schema version when other index metadata exists: after retrieving
stored_schema via
tx.get::<crate::tables::IndexMeta>(IndexMetaKey::SchemaVersion.into()), if
stored_schema is None then check for presence of other meta keys (e.g.
tx.get::<crate::tables::IndexMeta>(IndexMetaKey::ChainId.into()) and
tx.get(...IndexMetaKey::GenesisHash.into())); if any of those return Some(_)
then return an error (use ReferenceIndexError::SchemaMismatch or a new
MissingSchemaVersion variant) instead of falling through, otherwise allow
initialization as before. Ensure you still compare Some(v) != SCHEMA_VERSION as
in the existing match.

In `@crates/reference-index/src/reconcile.rs`:
- Around line 84-90: The rollback path in reconcile.rs currently deletes
BlockReferenceIndex and IndexedBlocks via delete_block(&tx, number) and updates
indexed_to, but it does not remove associated ReferenceIndex rows, leaving
reverted tx hashes still queryable by reader.query(); modify the rollback helper
to also delete matching ReferenceIndex entries within the same transaction
(extend delete_block or add a delete_reference_index(&tx, number) call so
ReferenceIndex rows for that block are removed before tx.commit()), and add a
reorg regression test that performs an offline reorg and asserts that
reader.query() no longer returns reverted transaction hashes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bdde8bdd-87f9-4290-b548-ddd0771e0c77

📥 Commits

Reviewing files that changed from the base of the PR and between faec4c8 and 4afc286.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (24)
  • Cargo.toml
  • bin/morph-reth/Cargo.toml
  • bin/morph-reth/src/main.rs
  • crates/node/Cargo.toml
  • crates/node/src/add_ons.rs
  • crates/node/src/exex/mod.rs
  • crates/node/src/exex/reference_index.rs
  • crates/node/src/lib.rs
  • crates/node/tests/it/main.rs
  • crates/node/tests/it/reference_index.rs
  • crates/reference-index/Cargo.toml
  • crates/reference-index/src/backfill.rs
  • crates/reference-index/src/db.rs
  • crates/reference-index/src/lib.rs
  • crates/reference-index/src/reader.rs
  • crates/reference-index/src/reconcile.rs
  • crates/reference-index/src/tables.rs
  • crates/reference-index/src/types.rs
  • crates/reference-index/src/writer.rs
  • crates/rpc/Cargo.toml
  • crates/rpc/src/lib.rs
  • crates/rpc/src/morph/handler.rs
  • crates/rpc/src/morph/mod.rs
  • crates/rpc/src/morph/rpc.rs

Comment on lines +95 to +104
// Forward the startup FinishedHeight when Task A finishes.
changed = startup_rx.changed() => {
if changed.is_ok() && let Some(block) = *startup_rx.borrow_and_update() {
debug!(
target: "morph::reference_index",
block_number = block.number,
"startup complete; forwarding initial FinishedHeight"
);
send_finished_height_monotonic(&ctx.events, block, &mut last_finished)?;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Guard the startup FinishedHeight against intervening reorgs.

A shorter ChainReorged can be handled before startup_rx.changed() wins the select!. After that, this branch can still forward the stale startup (number, hash) because send_finished_height_monotonic only compares heights. That re-advances the pruning marker to a non-canonical block after the reorg.

At minimum, re-check that the startup payload still matches control.db.indexed_to() and the current canonical hash before sending it, or drop the startup payload once any notification has already mutated the index.

Also applies to: 359-370

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/node/src/exex/reference_index.rs` around lines 95 - 104, The startup
branch forwards a potentially stale FinishedHeight (startup_rx) that can be
rendered non-canonical by an intervening ChainReorged; before calling
send_finished_height_monotonic(&ctx.events, block, &mut last_finished)
re-validate the startup payload against the current index state and canonical
hash (e.g. compare block.number and block.hash to control.db.indexed_to() and
the chain's canonical hash) and skip/dismiss the startup payload if any
notification (ChainReorged or other index mutations) has already advanced or
changed the canonical head; apply the same guard to the other occurrence around
send_finished_height_monotonic noted in the review (lines referenced 359-370) to
avoid re-advancing pruning to a non-canonical block.

Comment on lines +169 to +181
let stored_schema = tx
.get::<crate::tables::IndexMeta>(IndexMetaKey::SchemaVersion.into())?
.map(decode_u32)
.transpose()?;
match stored_schema {
Some(v) if v != SCHEMA_VERSION => {
return Err(ReferenceIndexError::SchemaMismatch {
expected: SCHEMA_VERSION,
actual: v,
});
}
_ => {}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject missing schema_version on reopen.

If ChainId/GenesisHash exist but SchemaVersion is absent, this branch falls through and opens the DB as current schema. That bypasses the compatibility check and lets a pre-schema or partially initialized index be read with the new layout.

Suggested fix
         let stored_schema = tx
             .get::<crate::tables::IndexMeta>(IndexMetaKey::SchemaVersion.into())?
             .map(decode_u32)
             .transpose()?;
         match stored_schema {
-            Some(v) if v != SCHEMA_VERSION => {
+            Some(v) if v != SCHEMA_VERSION => {
                 return Err(ReferenceIndexError::SchemaMismatch {
                     expected: SCHEMA_VERSION,
                     actual: v,
                 });
             }
-            _ => {}
+            Some(_) => {}
+            None => {
+                return Err(ReferenceIndexError::Other(eyre::eyre!(
+                    "reference index schema_version missing"
+                )));
+            }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let stored_schema = tx
.get::<crate::tables::IndexMeta>(IndexMetaKey::SchemaVersion.into())?
.map(decode_u32)
.transpose()?;
match stored_schema {
Some(v) if v != SCHEMA_VERSION => {
return Err(ReferenceIndexError::SchemaMismatch {
expected: SCHEMA_VERSION,
actual: v,
});
}
_ => {}
}
let stored_schema = tx
.get::<crate::tables::IndexMeta>(IndexMetaKey::SchemaVersion.into())?
.map(decode_u32)
.transpose()?;
match stored_schema {
Some(v) if v != SCHEMA_VERSION => {
return Err(ReferenceIndexError::SchemaMismatch {
expected: SCHEMA_VERSION,
actual: v,
});
}
Some(_) => {}
None => {
return Err(ReferenceIndexError::Other(eyre::eyre!(
"reference index schema_version missing"
)));
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/reference-index/src/db.rs` around lines 169 - 181, The code currently
allows reopening when IndexMeta::SchemaVersion is missing (stored_schema ==
None), which lets a pre-schema DB be opened as if current; update the reopen
logic to reject a missing schema version when other index metadata exists: after
retrieving stored_schema via
tx.get::<crate::tables::IndexMeta>(IndexMetaKey::SchemaVersion.into()), if
stored_schema is None then check for presence of other meta keys (e.g.
tx.get::<crate::tables::IndexMeta>(IndexMetaKey::ChainId.into()) and
tx.get(...IndexMetaKey::GenesisHash.into())); if any of those return Some(_)
then return an error (use ReferenceIndexError::SchemaMismatch or a new
MissingSchemaVersion variant) instead of falling through, otherwise allow
initialization as before. Ensure you still compare Some(v) != SCHEMA_VERSION as
in the existing match.

Comment on lines +84 to +90
let tx = db.tx_mut()?;
for number in fh..=indexed_to {
delete_block(&tx, number)?;
}
let new_indexed_to = fh.saturating_sub(1);
update_indexed_to(&tx, new_indexed_to)?;
tx.commit()?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Rollback must also purge ReferenceIndex rows.

This path rewinds indexed_to, but reader.query() reads from ReferenceIndex, and the provided crates/reference-index/src/writer.rs:72-109 snippet shows delete_block() only removes BlockReferenceIndex plus IndexedBlocks. After an offline reorg, reverted tx hashes can therefore stay queryable even though the block was rolled back. Please make the rollback helper delete the matching ReferenceIndex entries in the same transaction and add a reorg regression test for that path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/reference-index/src/reconcile.rs` around lines 84 - 90, The rollback
path in reconcile.rs currently deletes BlockReferenceIndex and IndexedBlocks via
delete_block(&tx, number) and updates indexed_to, but it does not remove
associated ReferenceIndex rows, leaving reverted tx hashes still queryable by
reader.query(); modify the rollback helper to also delete matching
ReferenceIndex entries within the same transaction (extend delete_block or add a
delete_reference_index(&tx, number) call so ReferenceIndex rows for that block
are removed before tx.commit()), and add a reorg regression test that performs
an offline reorg and asserts that reader.query() no longer returns reverted
transaction hashes.

SnapshotBlockNumber/SnapshotBlockHash metadata keys and validate_paired_snapshot()
had no write path — ops tar-compress the datadir directly so these fields were
always empty and the check was always a no-op. chain_id + genesis_hash at open()
plus reconcile's canonical hash check already cover the mismatch cases that matter.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
crates/node/src/exex/reference_index.rs (1)

95-104: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Re-validate the startup FinishedHeight before forwarding it.

This is still racy with shorter reorgs. If a ChainReorged is handled before this watch arm runs, send_finished_height_monotonic can re-send the stale startup (number, hash) and move pruning back onto a non-canonical block.

Suggested fix
             changed = startup_rx.changed() => {
                 if changed.is_ok() && let Some(block) = *startup_rx.borrow_and_update() {
+                    let current_number = control.db.indexed_to()?;
+                    let current_hash = ctx.components.provider().block_hash(current_number)?;
+                    if current_number != block.number || current_hash != Some(block.hash) {
+                        debug!(
+                            target: "morph::reference_index",
+                            startup_number = block.number,
+                            current_number,
+                            "dropping stale startup FinishedHeight"
+                        );
+                        continue;
+                    }
                     debug!(
                         target: "morph::reference_index",
                         block_number = block.number,
                         "startup complete; forwarding initial FinishedHeight"
                     );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/node/src/exex/reference_index.rs` around lines 95 - 104, Re-validate
the startup FinishedHeight after the startup_rx.changed() branch but before
calling send_finished_height_monotonic: after reading block from
startup_rx.borrow_and_update(), check the current chain tip or recent reorg flag
(the same check used when handling ChainReorged events) to ensure the (number,
hash) is still canonical; if it is no longer canonical skip forwarding and do
not update last_finished. Locate the watch arm using startup_rx, the call to
send_finished_height_monotonic(&ctx.events, block, &mut last_finished), and the
ChainReorged handling logic to reuse the canonical-validation check so the
startup message cannot re-send a stale FinishedHeight.
crates/reference-index/src/db.rs (1)

167-178: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject reopen when schema_version is missing.

If ChainId/GenesisHash are already persisted but SchemaVersion is absent, this still falls through as success. That lets a pre-schema or partially initialized index be read with the current layout.

Suggested fix
         match stored_schema {
             Some(v) if v != SCHEMA_VERSION => {
                 return Err(ReferenceIndexError::SchemaMismatch {
                     expected: SCHEMA_VERSION,
                     actual: v,
                 });
             }
-            _ => {}
+            Some(_) => {}
+            None => {
+                return Err(ReferenceIndexError::Other(eyre::eyre!(
+                    "reference index schema_version missing"
+                )));
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reference-index/src/db.rs` around lines 167 - 178, The code currently
lets stored_schema == None pass, allowing a partially-initialized index to be
treated as valid; update the match on stored_schema (the result of
tx.get::<crate::tables::IndexMeta>(IndexMetaKey::SchemaVersion.into())) so that
None is treated as an error rather than success: either return
Err(ReferenceIndexError::SchemaMissing) if you prefer a dedicated error variant,
or return Err(ReferenceIndexError::SchemaMismatch { expected: SCHEMA_VERSION,
actual: 0 }) for backwards compatibility; make this change in the stored_schema
match branch (and add the new ReferenceIndexError::SchemaMissing variant if you
choose that route).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/reference-index/src/writer.rs`:
- Around line 151-160: The function set_jade_first_block_number in writer.rs is
not formatted to project rustfmt standards; run rustfmt (cargo fmt) on the file
(or the repo) to reformat this function and surrounding code so the Rustfmt CI
job passes, ensuring code around IndexMetaKey::JadeFirstBlockNumber,
encode_u64(block_number), and the tx.put::<IndexMeta>(...) call is adjusted to
the project's style rules.

---

Duplicate comments:
In `@crates/node/src/exex/reference_index.rs`:
- Around line 95-104: Re-validate the startup FinishedHeight after the
startup_rx.changed() branch but before calling send_finished_height_monotonic:
after reading block from startup_rx.borrow_and_update(), check the current chain
tip or recent reorg flag (the same check used when handling ChainReorged events)
to ensure the (number, hash) is still canonical; if it is no longer canonical
skip forwarding and do not update last_finished. Locate the watch arm using
startup_rx, the call to send_finished_height_monotonic(&ctx.events, block, &mut
last_finished), and the ChainReorged handling logic to reuse the
canonical-validation check so the startup message cannot re-send a stale
FinishedHeight.

In `@crates/reference-index/src/db.rs`:
- Around line 167-178: The code currently lets stored_schema == None pass,
allowing a partially-initialized index to be treated as valid; update the match
on stored_schema (the result of
tx.get::<crate::tables::IndexMeta>(IndexMetaKey::SchemaVersion.into())) so that
None is treated as an error rather than success: either return
Err(ReferenceIndexError::SchemaMissing) if you prefer a dedicated error variant,
or return Err(ReferenceIndexError::SchemaMismatch { expected: SCHEMA_VERSION,
actual: 0 }) for backwards compatibility; make this change in the stored_schema
match branch (and add the new ReferenceIndexError::SchemaMissing variant if you
choose that route).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f60dc856-8bd6-4037-8652-db2b6fbb3c99

📥 Commits

Reviewing files that changed from the base of the PR and between 4afc286 and b6aa870.

📒 Files selected for processing (3)
  • crates/node/src/exex/reference_index.rs
  • crates/reference-index/src/db.rs
  • crates/reference-index/src/writer.rs

Comment on lines +151 to +160
pub fn set_jade_first_block_number<Tx: DbTxMut>(
tx: &Tx,
block_number: u64,
) -> Result<(), ReferenceIndexError> {
tx.put::<IndexMeta>(
IndexMetaKey::JadeFirstBlockNumber.into(),
encode_u64(block_number),
)?;
Ok(())
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Run cargo fmt on this file before merge.

The Rustfmt job is failing on writer.rs, so this block still needs formatting cleanup to unblock CI.

🧰 Tools
🪛 GitHub Actions: Lint / 0_Rustfmt.txt

[error] 159-159: cargo fmt --all -- --check: formatting differences detected. Run 'cargo fmt' to format crates/reference-index/src/writer.rs.

🪛 GitHub Actions: Lint / Rustfmt

[error] 159-159: Command failed: cargo fmt --all -- --check. Formatting issues detected in writer.rs (diff around line 159).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/reference-index/src/writer.rs` around lines 151 - 160, The function
set_jade_first_block_number in writer.rs is not formatted to project rustfmt
standards; run rustfmt (cargo fmt) on the file (or the repo) to reformat this
function and surrounding code so the Rustfmt CI job passes, ensuring code around
IndexMetaKey::JadeFirstBlockNumber, encode_u64(block_number), and the
tx.put::<IndexMeta>(...) call is adjusted to the project's style rules.

- ReferenceIndexDb: drop highest_indexed_block / highest_block_reference_index
  (no callers in the workspace)
- ReferenceIndexDb: remove unused DbCursorRO import exposed by the deletion
- ReferenceIndexReader: drop db() accessor (no callers in the workspace)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant