Skip to content

Capture native-precompile state changes in Firehose tracer#14

Merged
sduchesneau merged 2 commits into
firehose/2.xfrom
firehose/capture-precompile-state-changes
Jun 19, 2026
Merged

Capture native-precompile state changes in Firehose tracer#14
sduchesneau merged 2 commits into
firehose/2.xfrom
firehose/capture-precompile-state-changes

Conversation

@sduchesneau

Copy link
Copy Markdown

Problem

Native precompiles (B-20 tokens, the activation/policy registries) write storage and emit logs directly on the journal via EvmInternals — no SSTORE/LOG opcode runs. The Firehose inspector captured:

  • storage in step_end, gated on the SSTORE opcode
  • logs in log_full, fired by the LOG opcode

Neither fires for a precompile, so for any precompile call:

  • the call frame carried 0 logs while the receipt carried them → assign_ordinal_and_index_to_receipt_logs panics (mismatch between call logs and receipt logs: transaction has 0 call logs but 1 receipt logs)
  • storage writes vanished silently (no call/receipt validator for storage)

Observed on base-sepolia for txs to 0x8453000000000000000000000000000000000001 (activation registry).

Fix

Gather both in call_end, after process_journal_changes and before the frame is popped, so they attach to the precompile call that produced them:

  • gather_precompile_logs — emits journal logs log_full never saw (trx_logs_count high-water-mark)
  • gather_precompile_storage_changes — emits StorageChanged journal entries step_end never saw. A new storage_processed_up_to mark (advanced by step_end) prevents re-emitting opcode SSTOREs; reverted calls have their entries truncated by revm so the clamp drops them.

revm's Inspector::log_full rustdoc anticipates exactly this: "This will not happen only if custom precompiles where logs will be gathered after precompile call."

Balance, nonce and code changes were already captured via the journal-driven process_journal_changes path (verified against a live trace), so only storage + logs needed gathering.

Tests

crates/firehose/src/inspector.rs:

  • precompile_journal_storage_and_logs_are_captured — drives a tx whose only call writes a slot + emits a log directly on the journal, decodes the FIRE BLOCK, asserts the call carries the storage change (key/old/new) and the log.
  • precompile_logs_missing_without_gather_panics#[should_panic], pins the original bug.

Full reth-firehose lib suite: 20/20 pass. Mutation-checked: disabling either gather fails its assertion.

🤖 Generated with Claude Code

Native precompiles (B-20 tokens, activation/policy registries) write
storage and emit logs directly on the journal via EvmInternals, without
executing SSTORE/LOG opcodes. The inspector captured storage in step_end
(gated on the SSTORE opcode) and logs in log_full (the LOG opcode), so
both were lost for precompile calls: the call frame carried 0 logs while
the receipt carried them, panicking the receipt-log reconciliation, and
storage writes vanished silently.

Gather both in call_end, after process_journal_changes and before the
frame is popped, so they attach to the precompile call that produced
them. A storage_processed_up_to high-water-mark (advanced by step_end)
prevents re-emitting opcode SSTOREs. revm's Inspector::log_full rustdoc
anticipates exactly this: "This will not happen only if custom
precompiles where logs will be gathered after precompile call."

Balance, nonce and code changes were already captured via the
journal-driven process_journal_changes path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sduchesneau sduchesneau requested a review from maoueh June 19, 2026 17:20

@maoueh maoueh left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approving since it touches only tests

Comment on lines +2193 to +2207
fn decode_fire_block(raw: &[u8]) -> pb::sf::ethereum::r#type::v2::Block {
use base64::Engine as _;
use prost::Message as _;

let text = std::str::from_utf8(raw).expect("FIRE output is UTF-8");
let line = text
.lines()
.find(|l| l.starts_with("FIRE BLOCK "))
.expect("a FIRE BLOCK line");
let payload = line.split(' ').next_back().expect("payload token");
let bytes = base64::engine::general_purpose::STANDARD
.decode(payload)
.expect("base64 payload");
pb::sf::ethereum::r#type::v2::Block::decode(bytes.as_slice()).expect("protobuf Block")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I would ask to check if evm-firehose-tracer-rs doesn't have something for this already...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Checked — firehose-tracer (evm-firehose-tracer-rs) exposes no FIRE BLOCK parser; InMemoryBuffer only offers get_bytes(), and the decoded Block is private to the Tracer. So the ~6-line decode_fire_block (split the base64 payload off the FIRE BLOCK line, prost-decode) stays. Happy to upstream a small parse_fire_block helper into firehose-tracer if you prefer reuse across crates.

Comment thread crates/firehose/src/inspector.rs Outdated
Comment on lines +2168 to +2171
if gather {
insp.gather_precompile_storage_changes(&mut ctx);
insp.gather_precompile_logs(&mut ctx);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This doesn't excerise on_call_end which added the two gather, let's wire the test directly with correct implementation and not a fake try.

The panic check test case should be removed

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 83df090. The test now goes through the real Inspector::call / call_end hooks (call_end is what runs the two gathers); the precompile body is simulated by writing the storage slot + log straight on the journal between the hooks, exactly as EvmInternals does with no opcode. Removed the should_panic test — the single test still covers the bug (without the log gather, on_tx_end panics on the call/receipt log mismatch).

Comment thread crates/firehose/src/inspector.rs Outdated
Comment on lines +2068 to +2072
const PRECOMPILE: Address = Address::new([
0x84, 0x53, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
]);

fn legacy_tx_event() -> firehose_tracer::types::TxEvent {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Test helpers should go below where they used ideally, will adapt our sf-skill for this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 83df090drive_precompile_call / legacy_tx_event / decode_fire_block now sit below the #[test] that uses them.

Address review: exercise the production Inspector::call / call_end path
(which runs the gathers) instead of calling the private gather helpers
directly; drop the now-redundant should_panic test; move test helpers
below their use site.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sduchesneau sduchesneau merged commit 0b5ed5c into firehose/2.x Jun 19, 2026
2 checks passed
@sduchesneau sduchesneau deleted the firehose/capture-precompile-state-changes branch June 19, 2026 18:36
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.

2 participants