From 1e5bbe854e781c8c7a161bf158c2b645a2fe75c0 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 28 Jul 2026 20:33:25 +0100 Subject: [PATCH 1/8] Add Flashblocks preconfirmation runtime --- .github/workflows/ci.yml | 53 +- .gitignore | 33 + CHANGELOG.md | 192 +- CONTRIBUTING.md | 13 +- Cargo.lock | 418 +- Cargo.toml | 30 +- README.md | 301 +- RELEASING.md | 75 +- SECURITY.md | 59 + docs/KNOWN_ISSUES.md | 78 +- docs/ROADMAP.md | 31 +- docs/phase-6-bundle-sim-spec.md | 2 +- examples/reactive_alloy_amm_live_probe.rs | 2 +- examples/reactive_engine_lifecycle.rs | 95 +- examples/reactive_runtime.rs | 8 +- scripts/check-security-exceptions.sh | 201 + src/cache/durable_checkpoint.rs | 1186 + src/cache/mod.rs | 291 +- src/cache/versioned.rs | 43 +- src/freshness.rs | 22 +- src/lib.rs | 15 +- src/mapping_probe.rs | 4 +- src/reactive/mod.rs | 21816 ++++++++++++----- tests/block_context.rs | 178 +- tests/bundle_simulation.rs | 13 +- tests/call_tracer.rs | 9 +- tests/code_seeding.rs | 4 +- tests/cold_start.rs | 7 +- tests/durable_checkpoint.rs | 3697 +++ tests/event_pipeline.rs | 2 +- tests/freshness.rs | 2 +- tests/liveness_cold_start.rs | 4 +- tests/liveness_root_gate.rs | 260 +- tests/reactive_alloy_subscriber.rs | 653 +- tests/reactive_async_registration.rs | 328 + tests/reactive_engine.rs | 1472 +- tests/reactive_flashblocks.rs | 206 + tests/reactive_freshness.rs | 63 +- tests/reactive_health.rs | 46 +- tests/reactive_registry.proptest-regressions | 7 + tests/reactive_registry.rs | 289 +- tests/reactive_reorg.rs | 3808 ++- tests/reactive_resync.rs | 6 +- tests/reactive_router.rs | 21 +- tests/reactive_runtime.rs | 85 +- tests/reactive_subscriber_ingest.rs | 47 +- tests/reactive_trace_resync.rs | 4 +- 47 files changed, 29439 insertions(+), 6740 deletions(-) create mode 100755 scripts/check-security-exceptions.sh create mode 100644 src/cache/durable_checkpoint.rs create mode 100644 tests/durable_checkpoint.rs create mode 100644 tests/reactive_async_registration.rs create mode 100644 tests/reactive_flashblocks.rs create mode 100644 tests/reactive_registry.proptest-regressions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6af5273..bfeca66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,75 +10,82 @@ env: permissions: contents: read - checks: write jobs: check: name: release gates runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: + toolchain: stable components: clippy, rustfmt - name: Cache cargo artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Formatting run: cargo fmt --all --check - name: Clippy (all features) - run: cargo clippy --all-targets --all-features --no-deps -- -D warnings + run: cargo clippy --locked --all-targets --all-features --no-deps -- -D warnings - name: Tests (all targets, all features) - run: cargo test --all-targets --all-features + run: cargo test --locked --all-targets --all-features - name: Doc tests (all features) - run: cargo test --doc --all-features + run: cargo test --locked --doc --all-features - name: Docs (all features) - run: cargo doc --no-deps --all-features + run: cargo doc --locked --no-deps --all-features env: RUSTDOCFLAGS: "-D warnings" # --all-features enables reactive-ws, which shadows the polling-only and # no-reactive cfg branches. Exercise those distinct compile paths too so a # feature-gated regression cannot slip through. - - name: Feature matrix (polling-only + no-reactive core) + - name: Feature matrix run: | - cargo clippy --all-targets --no-default-features --features reactive-polling --no-deps -- -D warnings - cargo test --no-default-features --features reactive-polling - cargo build --no-default-features + cargo check --locked --no-default-features + cargo check --locked --no-default-features --features reactive + cargo check --locked --no-default-features --features reactive-polling + cargo check --locked --no-default-features --features reactive-ws + cargo clippy --locked --all-targets --no-default-features --features reactive-polling --no-deps -- -D warnings + cargo test --locked --no-default-features --features reactive-polling - name: Benchmarks compile - run: cargo bench --no-run --all-features + run: cargo bench --locked --no-run --all-features - name: Package run: cargo package --locked + - name: Verify security-exception scopes + run: bash scripts/check-security-exceptions.sh + + - name: Install cargo-audit + run: cargo install cargo-audit --locked --version 0.22.2 + - name: Security audit - uses: rustsec/audit-check@v2.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - # ark-relations records tracing-subscriber 0.2.25 as an optional - # dependency, but it is absent from the active all-target/all-feature - # graph (`cargo tree -i tracing-subscriber@0.2.25 --target all`). - ignore: RUSTSEC-2025-0055 + # The scope script requires the exact unreachable 0.2.25 lock entry and + # proves every active tracing-subscriber is patched (>= 0.3.20). + run: cargo audit --ignore RUSTSEC-2025-0055 msrv: name: msrv (1.88) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install Rust 1.88 (declared MSRV) - uses: dtolnay/rust-toolchain@1.88.0 + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + with: + toolchain: 1.88.0 - name: Cache cargo artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 # The published library must build on the MSRV advertised in Cargo.toml. # Scoped to --lib so the dev-only example/bench toolchain requirements diff --git a/.gitignore b/.gitignore index 53eaa21..c16b6d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,35 @@ /target **/*.rs.bk +.env +.env.* +!.env.example + +# Local runtime state and durable checkpoints. +/cache/ +/.cache/ +/.evm-fork-cache/ +/chain_*/ +/*.db +/*.db-* +/*.sqlite +/*.sqlite-* +/*.sqlite3 +/*.sqlite3-* +/*.checkpoint +/*.checkpoint.* +/checkpoint*.bin +/.checkpoint*.tmp-* +/evm_state.bin +/state.bin +/bytecodes.bin +/immutable_data.bin +/code_seeds.bin +/roots.bin +/observations.bin +/registry.bin + +# Local credentials and signing material. +*.key +*.pem +*.p12 +*.pfx diff --git a/CHANGELOG.md b/CHANGELOG.md index 704312d..0914063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,187 @@ surface freezes at 1.0. ## [Unreleased] -Remaining from the 0.2.0 plan and tracked as fast-follow: the toy -constant-product AMM walkthrough and the dedicated reactive integration cases -enumerated in [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md). +## [0.4.0-alpha.1] - 2026-07-28 + +### Migration checklist + +- Await reactive engine registration, owner synchronization, and unregistration + calls. `EventSubscriber::register_interests` and mutating + `InterestOwnerSubscriber` methods now return `SubscriberOperation`; custom + implementations must preserve the previous committed desired state on error + or cancellation, or block delivery until reconciliation. +- Handle the now-fallible `ReactiveEngine::into_parts`: `Ok((runtime, + subscriber))` is returned only with no pending delivery commit; + `Err(Box)` preserves an acknowledgement/checkpoint retry that must be + completed first. +- `register_handler_with_backfill` now accepts only one hash-certified block + still present in the rollback journal. Use ordinary global canonical catch-up + for deeper history; owner-only historical effects cannot safely outlive their + journal attachment. +- Add the new `SubscriberConfig` pending-record, pending-backfill, + historical-response-byte, and reconcile-concurrency fields, or use + `..SubscriberConfig::default()` in struct literals. +- Provider-backed and composite subscribers should implement `chain_id()`, + resolve and reject mixed networks before delivery, stamp every record, and + bind control-only batches with `ReactiveInputBatch::with_chain_id`. +- Advertise `DurableReplay` only when the complete committed position can be + restored without a gap. A composite may reconcile a rebuilt ephemeral live + child from durable history, but must close the cutover before delivery. ACK + must be idempotent, a replayed token must map to one immutable delivery, and a + failed `restore_position` must preserve the old position or retain only that + exact restore as delivery-blocking pending intent. Checkpointed ingest/restore + now rejects ephemeral subscribers, and `SubscriberResumePosition::new` + requires the chain id. +- Attach a stable `SubscriberPayloadCommitment` with + `ReactiveInputBatch::with_payload_commitment` whenever a tokened delivery + contains `BlockHeader`, `FullBlock`, or hydrated `PendingTx` payloads. The + commitment must be derived from a deterministic canonical encoding; without + it, durable checkpoint ingestion rejects payloads that the core cannot + witness completely. +- Replace empty handler identities. `HandlerId::new("")` now panics and + deserialization rejects the reserved empty value; use a stable non-empty id, + or `HandlerId::try_new` for untrusted input. +- Reinstall manual block-environment overrides after `EvmCache::set_block` or + `repin_to_block`. Repinning clears `NUMBER`, `BASEFEE`, `COINBASE`, + `PREVRANDAO`, `GASLIMIT`, and timestamp provenance; use `advance_block` when + a complete canonical header is available. + +### Added + +- Added provider-provenanced preconfirmation batches and a disposable cache + branch that exposes speculative state without advancing canonical coverage, + durability, finality, or health. +- Added Base-native `newFlashblocks` plus filtered `pendingLogs` correlation, + including cumulative payload sequencing, bounded unmatched-log retention, + reconnect reset, and pending-state gap recovery. +- Added an OP Flashblocks adapter over the standard pending block/log surface, + with preferred fallback and required-mode validation. +- Added provider IDs and generations to reactive input reports so downstream + reads can prefer the endpoint that announced not-yet-universal state. + +- `ReactiveInputBatch` can carry an opaque `SubscriberDeliveryToken`, and + `EventSubscriber::acknowledge_delivery` provides an idempotent post-ingest + commit hook. `ReactiveEngine::next_ingest*` acknowledges only after runtime + ingestion succeeds and reports acknowledgement failures distinctly, enabling + durable remote subscribers to replay an uncommitted batch safely. +- Added `DurableCheckpointStore` and versioned checkpoint metadata binding one + atomic cache snapshot to chain, subscriber, handler-schema, canonical block, + and delivery-token identity. The checkpointed `ReactiveEngine` loops enforce + ingest -> synced checkpoint -> ACK ordering, retry failed commits before + polling, and suppress a restored token's cross-process replay. Checkpoints + also retain provider-opaque resume bytes, safe/finalized heads, pending + repair work, handler provenance, freshness/root-gate state, metrics, and the + bounded rollback journal; barrier-certified empty ranges advance the durable + coverage anchor. Files have an integrity checksum and configurable size bound; + same-path stores share cancellation-safe in-process writer ordering. Encoding + and filesystem durability work run on Tokio's blocking pool. +- Durable delivery tokens now persist a core witness over validated identities, + exact log payloads, context, routing, controls, chain id, and provider cursor. + A same-token replay must reproduce it before skip-and-ACK; a manually supplied + token without a witness cannot use the replay shortcut. Network-generic full + block and hydrated-transaction bodies remain subject to the subscriber's + immutable-token contract. Tokenless barriers retain the prior token's witness + while advancing the overall provider cursor. +- Durable snapshots now acquire all backend map read locks as one coherent + capture window and normalize their exact pin, EVM block number, and timestamp + to checkpoint metadata. Header fields not proven by compact progress are + cleared. Checkpoint format version 6 records the replay witness and exact + full-header environment provenance. +- Added record-preserving `DeliveryAudience`, ordered in-band `ChainControl` + values for reorg/finality/barrier delivery, and fail-closed + `SubscriberCapabilities`. Alloy owner catch-up now preserves its exact + handler audience and non-canonical delivery scope through the generic + `EventSubscriber` path. +- Added the provider-neutral `CanonicalSequenceState`, + `CanonicalSequenceValidation`, and `CanonicalSequenceMutation` surface plus + `validate_canonical_sequence` / `normalize_and_validate_canonical_sequence`. + Composite sources can now validate a whole envelope, stage replayable + rewind/canonical/finality mutations, and normalize exact historical/live + overlap without depending on runtime or cache internals. The state's serde + representation is caller convenience, not a stable wire/checkpoint schema; + durable consumers must wrap it in their own versioned envelope. Diagnostic + counterparts return structured `CanonicalSequenceError` / + `CanonicalRollbackKind` values so history exhaustion never requires prose + matching; `retain_recent_history` gives external checkpoint owners an + explicit bounded rollback horizon. +- Added provider-neutral chain identity reporting and durable resume identity; + Alloy resolves `eth_chainId` once and stamps every emitted record. Added an + atomic cache/runtime/subscriber restore helper and bounded Alloy queues, + historical-response bytes, lazy backfills, and reconcile concurrency. + Control-only batches now require an explicit authoritative chain id. +- Added `ReactiveEngine::preview_durable_resume_position` for durable + subscribers that must asynchronously prepare a source before the synchronous + restore hook. Preview and restore use one validated runtime plan, so retained + canonical history, delivery identity, and provider cursor cannot drift. + +### Changed + +- Canonical validation now enforces exact parent-height/hash uniqueness across + sparse history, records, aliases, and controls; preserves resolved metadata + in rewind/finality mutations; rejects removed/canonical contradictions at any + height; and anchors exact removals at their authenticated N-1 parent without + discarding compatible safe/finalized state. Non-checkpointed deep recovery + clears unauthenticated history and emits a typed reorg report even when no + journal entry remains, while checkpointed validation continues to reject an + incomplete rollback. +- Chain controls now use explicit execution phases: reorgs run before + replacement records, while progress/barrier/finality controls commit after + the records they certify. Compact progress exact-hash pins the cache, retains + compatible same-block metadata, preserves only a verified exact full-header + environment, and keeps certified zero-event tails as durable journal anchors. +- `HandlerId` is now non-empty by construction and deserialization; the empty + identity remains reserved for canonical/global protocol scope. +- `EvmCache::set_block` and `repin_to_block` now clear every stale block-header + environment field, not only base fee. Manual overrides must be reinstalled + after a repin; `advance_block` remains the complete-header path. +- Durable checkpoint writes preflight encoded size before allocating the + payload, retry collision-resistant temporary names, preserve destination + symlink-entry replacement semantics, and fail with a typed unsupported error + on platforms where the crate cannot provide atomic replacement. Unix + temporary and final checkpoint files are owner-only (`0600`). +- `EventSubscriber::register_interests` and every mutating + `InterestOwnerSubscriber` operation now return a boxed, sendable + `SubscriberOperation` future. `ReactiveEngine` registration, bootstrap sync, + and unregistration consequently require `.await`, allowing remote subscribers + to wait for authoritative service-side acknowledgement. Handler registration + commits subscriber state before runtime routing, so failure or cancellation + cannot leave a runtime handler active without committed interests; failed + subscriber removal likewise preserves runtime routing. +- Explicit chain controls now reject mismatched old tips, regressing or + conflicting finality, backward/conflicting barriers, and reorgs that cross + the finalized head before mutating cache or runtime state. +- Canonical transition validation now requires exact adjacent parent anchors, + emits the complete dropped suffix before an implicit replacement, rejects + safe/finalized heads beyond coverage and broken adjacent snapshot links, and + retains equal-height metadata enrichment during overlap normalization. Older + compatible enrichment remains non-forwarding so extension and runtime state + converge. Durable checkpoint preflight now uses this same validator and maps + incomplete rollback proof to `CheckpointReorgOutsideJournal`; direct runtime + ingestion continues to surface/degrade on observable deep reorgs. +- Runtime batches now roll back cache state and canonical bookkeeping together + on failure while retaining monotonic rejected-attempt metrics. Delivery + acknowledgements remain pending across errors or task cancellation and are + retried before later polling; checkpointed hooks are dispatched only after + staging succeeds. +- Alloy stream reconciliation installs every successful source immediately and + retains unfinished subscribe-then-backfill intent across later connection + errors or task cancellation, so retries neither discard nor duplicate an + already-live source. +- Pending checkpoint retries now fail closed if the cache generation changed + after ingestion, preventing unrelated state from being persisted under an + older delivery/runtime checkpoint. Checkpointed explicit controls, implicit + parent mismatches, and removed/reorged records also fail before + mutation/save/ACK when complete recovery is not proven by the retained effect + journal; `journal_depth` must cover the subscriber's recovery horizon. +- Runtime input validation now rejects payload/context and cache-chain + disagreements, preserves distinct canonical/reorg and header/full-block + representations, and merges duplicate audiences only inside one batch. + Cross-batch replay suppression remains an explicit subscriber responsibility. +- Checkpointed ingest and restore now require an advertised `DurableReplay` + capability and reject ephemeral subscribers before polling or state mutation; + the in-crate Alloy subscriber intentionally remains an ordinary live source. +- Alloy dependencies are capped below 1.7 so fresh resolution cannot silently + select a release requiring a newer compiler than the declared Rust 1.88 MSRV. ## [0.3.0] - 2026-07-14 @@ -254,8 +432,9 @@ hedged picture): later). `register_handler` updates runtime routing and subscriber interests together and, once ingestion has journaled a canonical block, backfills the new handler from that block automatically — closing the discovery→subscription - gap with no caller bookkeeping (`register_handler_with_backfill` for deeper - history, `register_handler_live_only` to opt out; `sync_handler_interests` + gap with no caller bookkeeping (`register_handler_with_backfill` for one + explicit retained-block replay, `register_handler_live_only` to opt out; + `sync_handler_interests` bootstraps a pre-populated runtime). `unregister_handler` removes routing and transport for that handler only; the runtime adds `last_canonical_block()`, `pending_resyncs()`, `cancel_pending_resyncs(address)`, `handler_ids()`, @@ -882,7 +1061,8 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). - `EvmCache` requires a multi-thread tokio runtime for any RPC-touching path. - See [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md) for current limitations. -[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.1...HEAD +[0.4.0-alpha.1]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.3.0...v0.4.0-alpha.1 [0.3.0]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.1.0...v0.2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3da41b4..a7f9e0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,8 +19,7 @@ behind an `RPC_URL` environment variable and are skipped when it is unset. ## The green bar -CI runs the checks below, and every commit on a feature branch is expected to -pass **all** of them. Run them locally before pushing: +For ordinary development, run this fast default-feature loop before pushing: ```sh cargo fmt --all --check @@ -29,7 +28,7 @@ cargo test RUSTDOCFLAGS="-D warnings" cargo doc --no-deps ``` -A convenience one-liner: +A convenience one-liner for the same smoke loop: ```sh cargo fmt --all --check && \ @@ -38,6 +37,14 @@ cargo test && \ RUSTDOCFLAGS="-D warnings" cargo doc --no-deps ``` +This is not the complete release matrix. Changes to reactive delivery, +feature-gated code, persistence, or public APIs must also pass the locked +all-feature tests, doctests, clippy and rustdoc gates; the polling-only, +reactive-only, and no-reactive feature checks; the Rust 1.88 library check; +benchmark compilation; security-scope validation; and package verification in +[`RELEASING.md`](RELEASING.md). The CI workflow and release checklist must stay +synchronized, including the polling-only test run. + ### MSRV The minimum supported Rust version is **1.88** (edition 2024), enforced by a diff --git a/Cargo.lock b/Cargo.lock index 979b8bf..edaae0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,9 +37,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alloy-chains" -version = "0.2.35" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b5cc30538e90795a57647bef8d8864aad6e8d86190617009b4ef8d8b647b49a" +checksum = "c36ddb69f5e41407e7a93aead3480f7c8ff076e73d01a951ae8f7ea4542c1ca0" dependencies = [ "alloy-primitives", "num_enum", @@ -112,9 +112,9 @@ dependencies = [ [[package]] name = "alloy-dyn-abi" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a475bb02d9cef2dbb99065c1664ab3fe1f9352e21d6d5ed3f02cdbfc06ed1abc" +checksum = "9a04eb4abc2b5074a18e687ee63918f407cc7990083cba9b999445f839796060" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -243,9 +243,9 @@ dependencies = [ [[package]] name = "alloy-json-abi" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c36c9d7f9021601b04bfef14a4b64849f6d73116a4e91e071d7fbfe10247901" +checksum = "6cee30dd4c2f4b23f434fdf675e7bf9681b86768141277266c6f548ef25cba0a" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -331,15 +331,16 @@ dependencies = [ [[package]] name = "alloy-primitives" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" +checksum = "f007e257069855bdf21d27762fd3f3705a613f805c9a08309bf353503f081d71" dependencies = [ "alloy-rlp", "bytes", "cfg-if", "const-hex", "derive_more", + "fixed-cache", "foldhash", "hashbrown 0.17.1", "indexmap 2.14.0", @@ -439,7 +440,7 @@ checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -574,41 +575,41 @@ dependencies = [ [[package]] name = "alloy-sol-macro" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" +checksum = "b5655c38d5f84955bf727b2eeb62fddd91ebb98fd1d7ae6eb77f73ea88f9b9cf" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "alloy-sol-macro-expander" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" +checksum = "6277c780e07b76951e09a59788dde230d1582612324177d11a43a61e21a6bb83" dependencies = [ "alloy-sol-macro-input", "const-hex", "heck", "indexmap 2.14.0", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", "sha3", - "syn 2.0.118", + "syn 2.0.119", "syn-solidity", ] [[package]] name = "alloy-sol-macro-input" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" +checksum = "9762b2ad3e5a0c09886de54fe549ab0056681df843cb082e2df7e1c0eb270d30" dependencies = [ "const-hex", "dunce", @@ -616,15 +617,15 @@ dependencies = [ "macro-string", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "syn-solidity", ] [[package]] name = "alloy-sol-type-parser" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "857b470ecdd2ed38beaf82ad1a38c516a8ff75266750f38b9eeed001d575241b" +checksum = "da4c7130f0f01f4719678bda3db3bc7267fc2f7f9d0565e3bd964cd2bb45050d" dependencies = [ "serde", "winnow", @@ -632,9 +633,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" +checksum = "d96e74d6213180f78dbdccddce8af02a639c160c94b0a543fa35c77c58b8a7fc" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -665,6 +666,19 @@ dependencies = [ "wasmtimer", ] +[[package]] +name = "alloy-transport-balancer" +version = "0.3.0-alpha.1" +dependencies = [ + "alloy-json-rpc", + "alloy-transport", + "serde", + "serde_json", + "tokio", + "tower", + "tracing", +] + [[package]] name = "alloy-transport-http" version = "1.6.3" @@ -726,7 +740,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -752,9 +766,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "ark-bls12-381" @@ -886,7 +900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -924,7 +938,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1013,7 +1027,7 @@ checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1089,18 +1103,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -1138,7 +1152,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1197,21 +1211,20 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitcoin-consensus-encoding" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" dependencies = [ "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", ] [[package]] name = "bitcoin-internals" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" -dependencies = [ - "hex-conservative 0.3.2", -] +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" [[package]] name = "bitcoin-io" @@ -1234,9 +1247,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -1286,9 +1299,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" dependencies = [ "borsh-derive", "bytes", @@ -1297,15 +1310,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1367,9 +1380,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.67" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "shlex", @@ -1383,9 +1396,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chrono" @@ -1428,18 +1441,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstyle", "clap_lex", @@ -1704,7 +1717,7 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1717,7 +1730,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1728,7 +1741,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1739,7 +1752,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1800,7 +1813,7 @@ checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1822,7 +1835,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] @@ -1865,7 +1878,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1904,7 +1917,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1953,7 +1966,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1974,7 +1987,7 @@ dependencies = [ [[package]] name = "evm-fork-cache" -version = "0.3.0" +version = "0.4.0-alpha.1" dependencies = [ "alloy-consensus", "alloy-contract", @@ -1988,12 +2001,14 @@ dependencies = [ "alloy-rpc-types-eth", "alloy-sol-types", "alloy-transport", + "alloy-transport-balancer", "alloy-transport-http", "anyhow", "bincode", "criterion", "foundry-fork-db", "futures", + "proptest", "reqwest", "revm", "rustls", @@ -2016,9 +2031,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fastrlp" @@ -2058,6 +2073,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixed-cache" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583" +dependencies = [ + "equivalent", + "rapidhash", +] + [[package]] name = "fixed-hash" version = "0.8.0" @@ -2148,9 +2173,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -2163,9 +2188,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -2173,15 +2198,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -2190,38 +2215,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -2289,9 +2314,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "group" @@ -2387,9 +2412,9 @@ dependencies = [ [[package]] name = "hex-conservative" -version = "0.3.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ "arrayvec", ] @@ -2415,9 +2440,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2425,9 +2450,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -2453,9 +2478,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -2660,7 +2685,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2825,9 +2850,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -2879,7 +2904,7 @@ checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2900,9 +2925,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -3034,7 +3059,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3085,7 +3110,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3169,7 +3194,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3277,7 +3302,7 @@ dependencies = [ "phf_shared 0.13.1", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3315,7 +3340,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3428,32 +3453,32 @@ dependencies = [ ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro-error-attr3" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" dependencies = [ "proc-macro2", "quote", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-error3" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" dependencies = [ - "proc-macro-error-attr2", + "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3485,9 +3510,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3621,29 +3646,29 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "regex" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3653,9 +3678,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -4017,9 +4042,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "once_cell", "ring", @@ -4232,9 +4257,9 @@ checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -4242,29 +4267,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap 2.14.0", "itoa", @@ -4315,7 +4340,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4397,9 +4422,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simple_asn1" @@ -4436,9 +4461,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4490,7 +4515,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4512,9 +4537,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -4523,14 +4559,14 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" +checksum = "083be3061e64d362cbe6ef12cfe1307ba3884326d8856448fe8a120fa2c44ebf" dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4550,7 +4586,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4574,22 +4610,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -4612,9 +4648,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" dependencies = [ "deranged", "num-conv", @@ -4632,9 +4668,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -4677,9 +4713,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -4692,13 +4728,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4751,9 +4787,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -4773,9 +4809,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime", @@ -4861,7 +4897,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5128,7 +5164,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -5181,14 +5217,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.8", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -5223,7 +5259,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5234,7 +5270,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5345,9 +5381,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5411,28 +5447,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.54" +version = "0.8.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5452,7 +5488,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -5473,7 +5509,7 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5506,11 +5542,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index eb88b86..b51d7a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "evm-fork-cache" -version = "0.3.0" +version = "0.4.0-alpha.1" edition = "2024" rust-version = "1.88" license = "MIT OR Apache-2.0" @@ -37,16 +37,17 @@ reactive-ws = ["reactive", "alloy-provider/ws", "dep:rustls", "rustls/ring"] reactive-polling = ["reactive"] [dependencies] -alloy-consensus = "1.1.2" -alloy-contract = "1.0.38" -alloy-eips = "1.0.38" -alloy-network = "1.0.38" -alloy-primitives = { version = "1.4", features = ["map"] } -alloy-provider = "1.0.38" +alloy-consensus = ">=1.1.2, <1.7" +alloy-contract = ">=1.0.38, <1.7" +alloy-eips = ">=1.0.38, <1.7" +alloy-network = ">=1.0.38, <1.7" +alloy-primitives = { version = ">=1.4, <1.7", features = ["map"] } +alloy-provider = ">=1.0.38, <1.7" alloy-rlp = "0.3" -alloy-rpc-client = "1.0.38" -alloy-rpc-types-eth = "1.0.38" -alloy-sol-types = "1.4" +alloy-rpc-client = ">=1.0.38, <1.7" +alloy-rpc-types-eth = ">=1.0.38, <1.7" +alloy-sol-types = ">=1.4, <1.7" +alloy-transport-balancer = { version = "0.3.0-alpha.1", path = "../alloy-transport-balancer", default-features = false } futures = "0.3" bincode = "1.3" @@ -67,11 +68,12 @@ tracing = "0.1.41" [dev-dependencies] anyhow = "1.0.98" -alloy-node-bindings = "1.1.2" -alloy-rpc-client = { version = "1.0.38", features = ["reqwest"] } -alloy-transport = "1.0.38" -alloy-transport-http = "1.0.38" +alloy-node-bindings = ">=1.1.2, <1.7" +alloy-rpc-client = { version = ">=1.0.38, <1.7", features = ["reqwest"] } +alloy-transport = ">=1.0.38, <1.7" +alloy-transport-http = ">=1.0.38, <1.7" criterion = "0.5" +proptest = "1" # Gzip-capable HTTP client for the bulk-storage benchmark example: enabling the # `gzip` feature makes reqwest advertise `Accept-Encoding: gzip` and # transparently decompress responses — a large win for multi-hundred-KB diff --git a/README.md b/README.md index 9f15763..7374dd0 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,69 @@ around three capabilities that target exactly this workload: > exponential-backoff reconnect, and `get_logs` backfill) plus protocol-neutral > cold-start (2); and the optimistic verify-and-rerun loop (3). Honest remaining > transport work: full block bodies and full pending-transaction hydration are -> follow-ups; owner-scoped log backfill is available for newly added reactive -> interests. The public API still changes +> follow-ups. Mid-lifecycle log owners use coordinated subscribe-first catch-up: +> exact retained-block replay is owner-scoped, while later history uses global +> canonical routing so every handler and rollback journal stays aligned. The +> public API still changes > between minor versions — see [Stability](#stability). +## Migrating to 0.4 + +The reactive subscriber contract became asynchronous and explicitly durable in +0.4. Existing consumers and extension crates should make these changes: + +- Await `ReactiveEngine` handler registration, bootstrap synchronization, and + unregistration calls. Subscriber desired state now commits before runtime + routing changes. +- Update `EventSubscriber::register_interests` and every mutating + `InterestOwnerSubscriber` method (`upsert_*`, `replace_*`, owner add/backfill/ + coordinated-catchup, and removal) to return `SubscriberOperation`. A failed or + cancelled future must leave the previous desired state authoritative, or block + delivery until reconciliation. +- Handle `ReactiveEngine::into_parts` as fallible. An engine with a pending ACK + or checkpoint commit is returned intact in `Err(Box>)` so + protocol state cannot be silently discarded. +- Treat `register_handler_with_backfill` as exact retained-block replay only: + start, end, and hash-certified anchor must name one block still present in the + runtime rollback journal. Use ordinary global canonical catch-up for wider + history; `register_handler` performs the coordinated mid-lifecycle path. +- Construct `SubscriberConfig` with `..SubscriberConfig::default()` or provide + the new pending-record, pending-backfill, historical-byte, and reconcile + concurrency limits explicitly. +- Implement `EventSubscriber::chain_id` for provider-backed and composite + sources. Resolve one authoritative network before delivery, reject mixed + child networks, stamp record contexts, and set + `ReactiveInputBatch::with_chain_id` on control-only batches. +- Advertise `SubscriberCapability::DurableReplay` only when the complete + committed position survives reconnect and process restart without a gap. A + composite may rebuild an ephemeral live child by reconciling from a durable + historical cursor, but it must close that cutover before exposing live input. + Durable ACKs must be idempotent; a re-emitted token must identify the same + immutable delivery, and `restore_position` must preserve the old position on + error or retain only the exact restore as delivery-blocking pending intent. + Checkpointed ingest/restore rejects subscribers that do not uphold this + contract. `SubscriberResumePosition::new` now also requires the chain id. +- For every tokened batch containing `BlockHeader`, `FullBlock`, or hydrated + `PendingTx` records, attach a stable `SubscriberPayloadCommitment` with + `ReactiveInputBatch::with_payload_commitment`. Compute it from a deterministic + canonical encoding of the complete provider payload; durable ingestion fails + closed when the core cannot witness these generic payloads and no commitment + is present. +- Replace empty handler identities. `HandlerId::new("")` now panics and + deserialization rejects the reserved empty value; use a stable non-empty id, + or `HandlerId::try_new` when validating untrusted configuration. +- Reinstall manual block-environment overrides after `EvmCache::set_block` or + `repin_to_block`. Repinning clears `NUMBER`, `BASEFEE`, `COINBASE`, + `PREVRANDAO`, `GASLIMIT`, and timestamp provenance. Prefer `advance_block` + when a complete canonical header is available. +- Choose an explicit `SubscriberConfig::preconfirmations` policy. The default is + `PreconfirmationMode::Disabled`; `Preferred` falls back on unsupported chains, + while `Required` fails closed when the chain, transport, or stable provider + identity cannot supply Flashblocks. +- Attach a `ProviderRef` to Flashblocks-enabled `AlloySubscriber` sessions. The + endpoint ID is propagated into every preconfirmed record so pending reads can + remain pinned to the announcing provider and later canonical reads can prefer it. + ## What it provides today - **Forked EVM cache** backed by `foundry-fork-db` with lazy RPC loading and @@ -79,7 +138,7 @@ around three capabilities that target exactly this workload: - **Reactive runtime** — register pure handlers for logs, block notifications, and pending transaction signals. Handlers emit `StateUpdate`s, invalidations, resync requests, speculative signals, and hook signals; the runtime routes - inputs, deduplicates and orders canonical logs, validates pending semantics, + inputs, deduplicates and orders canonical logs within each batch, validates pending semantics, applies canonical cache mutations through `EvmCache::apply_updates`, and can optionally execute storage resync requests through the cache's provider-neutral storage batch fetcher before dispatching reports to hooks. @@ -93,9 +152,24 @@ around three capabilities that target exactly this workload: The Alloy subscriber additionally merges compatible logical filters into provider-side address/topic supersets and splits them only at `SubscriberConfig::max_log_addresses_per_subscription` (default `1,024`). + Cross-batch replay/overlap remains the subscriber's responsibility: the + runtime intentionally carries no unbounded global input history. The Alloy + subscriber maintains bounded canonical and per-owner dedupe windows, and + fails closed if configured pending-record, lazy-backfill, historical-response, + or reconcile-concurrency limits are exceeded. + Every batch also carries an authoritative chain identity when available. + Record contexts and subscriber identity must agree with `EvmCache::chain_id`, + and control-only reorg/finality/barrier batches must set + `ReactiveInputBatch::with_chain_id`; mismatches fail before runtime mutation. Every delivered log is still matched against its original owner filter locally, so fan-in reduces WebSocket round trips without broadening logical - delivery. + delivery. Historical catch-up retains handler provenance through + `DeliveryAudience`, including mixed batches with record-level audiences, so + overlapping existing handlers do not re-apply a new owner's backfill. Its + `DeliveryScope::OwnerCatchup` records update only the requesting handler and + cannot rewind global canonical coverage, block context, finality, or the + rollback journal; ordinary historical recovery uses + `DeliveryScope::CanonicalProgress` and remains authoritative. Handlers with a complete static route set can return a `LogRouteIndex` of exact emitter, topic, or data-slice keys; registry inspection and live ingestion then select only matching indexed handlers plus legacy fallback @@ -115,21 +189,29 @@ around three capabilities that target exactly this workload: immediately, retries three times by default with exponential backoff between later attempts, and backfills log subscriptions from the last seen block through `get_logs`, marking recovered records as `InputSource::Backfill` while - suppressing recent duplicate canonical inputs. HTTP polling `watch_logs` / + suppressing recent duplicate canonical inputs. Alloy catch-up issues one + complete-range `eth_getLogs` request per filter/window: the configured + response-byte limit rejects an oversized decoded result but does not split + the range or avoid provider result caps. Keep live registration/reconnect + windows bounded; use HyperSync (or another indexing `EventSubscriber`) for + deep or high-density history. HTTP polling `watch_logs` / `watch_pending_transactions` remains available behind the opt-in `reactive-polling` feature. For pool/feed churn (register a new AMM on a `PoolCreated` event; drop one that is no longer of interest), the recommended binding is `ReactiveEngine`, which owns a `ReactiveRuntime` plus an `EventSubscriber` and drives handler lifecycle as one operation: - `engine.register_handler(handler)` updates runtime routing and subscriber - interests together and — once ingestion has journaled a canonical block — - **backfills the new handler from that block automatically**, so a pool - discovered mid-stream misses none of its own logs between discovery and live - subscription (`register_handler_with_backfill` for deeper history, + `engine.register_handler(handler).await` commits subscriber interests before + runtime routing and — once ingestion has journaled a canonical block — + installs the live desired state first, replays the new owner at the retained + block, then catches the complete handler union up globally from the following + block through activation. A pool discovered mid-stream therefore misses no + logs and every later effect remains globally rollbackable + (`register_handler_with_backfill` for one explicit hash-certified + retained-block replay only, `register_handler_live_only` to opt out). Growing an existing handler's filter set is continuity-safe too: the changed subscription inherits the old delivery anchor and self-heals the gap. Use stable per-pool or per-adapter `HandlerId` - values. Dropping an adapter is `engine.unregister_handler(&id)` for + values. Dropping an adapter is `engine.unregister_handler(&id).await` for routing/transport, followed by `runtime.cancel_pending_resyncs_by_id(&request_ids)` once for all requests owned by that exact handler generation (or `cancel_pending_resync` for a @@ -138,6 +220,143 @@ around three capabilities that target exactly this workload: vaults require caller-side ownership tracking. Cache eviction stays an explicit caller action. Full block bodies and full pending transaction hydration remain explicit follow-up transport work. + + Durable subscribers may attach an opaque `SubscriberDeliveryToken` to each + `ReactiveInputBatch`; the engine invokes `acknowledge_delivery` only after + successful runtime ingestion (including the resync-executing path). A failed + acknowledgement is distinguishable from an ingest failure and leaves the + subscriber free to replay the batch with at-least-once semantics. For + restart-safe state, use `DurableCheckpointStore` with + `next_ingest_checkpointed` (or + `next_ingest_with_resync_checkpointed`): the engine atomically persists the + complete two-layer cache state, canonical block identity, subscriber identity, + handler-schema id, delivery token, and a core witness over that delivery's + validated identities, exact log payloads, routing, controls, and cursor + **before** acknowledgement. Network-generic full-block and hydrated-transaction + bodies remain part of the subscriber's immutable token contract because the + core cannot serialize every network response type. Disk or ACK failure + is retried before another batch is polled. A restored token suppresses + cross-process replay only when the incoming delivery reproduces its persisted + witness; token reuse with a different payload or cursor fails before ACK. + Once a commit is pending, a caller-side cache mutation fails closed rather + than being rebound to the older delivery metadata; restart from the last + durable checkpoint to recover that misuse. + Checkpointed ingestion and restore require the subscriber to advertise + `SubscriberCapability::DurableReplay`; the in-crate Alloy subscriber is an + ephemeral live transport and intentionally does not advertise it. Pair Alloy + with ordinary ingestion, or use a durable remote/provider extension for + restart-safe cursor replay. Load and + inspect the checkpoint metadata first and validate any non-finalized block + hash against an authoritative RPC. Prefer + `ReactiveEngine::restore_durable_checkpoint` to restore the already configured + cache, runtime, and subscriber atomically; the lower-level cache and engine + restore calls remain available when an application supplies its own + transaction boundary. A durable subscriber that needs asynchronous source + preparation before the synchronous restore hook can call + `ReactiveEngine::preview_durable_resume_position` on that same fresh engine, + await its provider-specific preparation with the returned position, and then + restore the identical checkpoint metadata. Preview and restore share one + validated runtime plan, including configured journal retention, so extensions + never need to decode the core's private runtime checkpoint or guess its + canonical history. Checkpointed ingestion needs a canonical coverage + anchor: a pending-transaction-only process must first restore an existing + canonical checkpoint or observe canonical progress, otherwise it returns + `MissingCheckpointBlock` without acknowledging the delivery. The ordinary warm-cache files + remain independent startup accelerators and are not a transaction boundary. + Durable files carry an integrity checksum and have a configurable 512 MiB + default encoded/file size ceiling. The ceiling bounds read and encode buffers, + but snapshot capture first owns a cache-state clone, whose memory must be + budgeted separately. The checksum detects damage but is not authentication; + protect the checkpoint path with normal service filesystem permissions. All + stores for one normalized path share in-process writer ordering, but a + deployment must still assign that path to exactly one writer process. + Snapshot capture holds the backend account, storage, and block-hash read locks + together, so queued lazy population cannot produce a torn combination of map + generations. The persisted exact block pin, `NUMBER`, and optional timestamp + are normalized to checkpoint metadata; header-only fields (`BASEFEE`, + `COINBASE`, `PREVRANDAO`, and `GASLIMIT`) are cleared when compact progress did + not prove them for that block. + Provider-neutral extensions can additionally attach an opaque + `SubscriberCheckpoint` for native resume state and advertise their exact + `SubscriberCapabilities`; the default capability set is empty so topology + validation fails closed. Reorg, safe/finalized, and source-cutover signals use + ordered in-band `ChainControl` values rather than an unordered side channel. + Barriers can certify an empty event range and advance the checkpoint coverage + anchor. `CanonicalProgress` and a block-bearing barrier prove **event-stream + coverage**, not a complete EVM header: they exact-hash pin lazy reads and + install known `NUMBER`/timestamp metadata, but clear unproven `BASEFEE`, + `COINBASE`, `PREVRANDAO`, and `GASLIMIT` values. A simulation that depends on + those opcodes is not header-ready until a full canonical header has been + ingested. Reorg controls execute before their replacement records; progress, + barrier, safe, and finalized controls execute after the records they certify. + A batch that interleaves those phases ambiguously is rejected before mutation. + Checkpoints persist the safe/finalized heads, pending repair queue, + bounded rollback journal, handler lifecycle provenance, freshness/root-gate + state, and metrics alongside cache state, so ACKed controls and in-window + rollback remain valid after restart. Contradictory controls—such as + a mismatched reorg old tip, finality regression, or a reorg crossing finalized + state—are rejected before mutation. Checkpointed ingestion also rejects an + explicit, implicit-parent, or removed-log reorg whose required rollback proof + is outside the retained effect journal, because partially rolled-back cache + state must never be saved and ACKed. Size + `ReactiveConfig::journal_depth` to at least the complete reorg horizon promised + by the subscriber. Direct batch ingestion is transactional on + errors by taking one complete mutable-cache snapshot; preserve source batching + to amortize that cost. Hooks run only after a batch has staged successfully, + but are in-process observers rather than a durable outbox, so externally + visible effects need idempotency and their own durable delivery. Serialization, + file writes, fsync, rename, and directory fsync run on Tokio's blocking pool; + snapshot capture itself is synchronous and temporarily owns that state copy. + The companion + [`evm-fork-cache-remote`](https://crates.io/crates/evm-fork-cache-remote) and + [`evm-fork-cache-hypersync`](https://crates.io/crates/evm-fork-cache-hypersync) + crates implement the versioned remote service client and a durable HyperSync + source without coupling provider-native types into this core crate. + +### Flashblocks on Base and OP + +Flashblocks are an opt-in subscriber mode layered onto the same handler and +runtime path as canonical events: + +```rust,no_run +use std::time::Duration; +use evm_fork_cache::reactive::{ + AlloySubscriber, PreconfirmationMode, ProviderRef, SubscriberConfig, + SubscriberMode, +}; +# use alloy_network::Ethereum; +# use alloy_provider::Provider; +# fn configure>(provider: P) { +let config = SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + flashblock_poll_interval: Duration::from_millis(100), + ..SubscriberConfig::default() +}; +let subscriber = AlloySubscriber::new(provider, SubscriberMode::PubSub, config) + .with_provider_ref(ProviderRef::new("flashblocks-primary", 1)); +# let _ = subscriber; +# } +``` + +- **Base** (`8453`, `84532`) consumes both native `newFlashblocks` markers and + filter-shaped `pendingLogs`. Logs are buffered until their partial-block hash + can be correlated with the cumulative Flashblock identity, regardless of + arrival order. Reconnect or index gaps recover from the endpoint's cumulative + `pending` snapshot. +- **OP** (`10`, `11155420`) samples the documented standard `pending` state at + `flashblock_poll_interval`, deduplicating cumulative logs while canonical + block subscriptions continue normally. `Required` verifies that the endpoint + actually exposes a pending block ahead of the canonical head. + +Both adapters emit `ChainStatus::Preconfirmed`, `InputSource::Flashblocks`, and +`DeliveryScope::Preconfirmed`. `ReactiveRuntime` applies each cumulative +Flashblock to a disposable overlay: a newer payload/provider generation replaces +the previous preview, canonical input restores the saved canonical state before +commit, and `discard_preconfirmation` restores it explicitly. Preconfirmed +resyncs use the `pending` block tag. The overlay never advances canonical +coverage, finality, health, rollback journals, or durable checkpoints; the +checkpointed engine rejects speculative batches rather than persisting them. + - **Cold-start** — declaratively warm a working set of accounts and storage slots into the cache in one batched pass via `EvmCache::run_cold_start` and a `ColdStartPlanner` (discover slots via a view-call, then verify them), returning @@ -202,6 +421,47 @@ around three capabilities that target exactly this workload: custom errors in one line. Duplicate custom-error selectors keep the first registration and can be rejected explicitly with `try_register*`. +### Composing event sources safely + +Remote and hybrid subscribers can keep a `CanonicalSequenceState` beside their +own cursor and validate each complete delivery with +`validate_canonical_sequence` before forwarding it. The result exposes the +post-reorg/pre-record state, the fully validated next state, and ordered +cache-free `CanonicalSequenceMutation`s. Implicit replacements identify the +exact adjacent surviving parent and emit `Rewind` before `Canonical`; safe and +finalized heads can never outrun canonical coverage. Strict public validation +and checkpointed engine ingestion reject any rollback outside the retained +history, while ordinary non-checkpointed runtime ingestion keeps its existing +observable deep-reorg/degraded-health behavior. Checkpoint preflight and runtime +validation use this same transition implementation rather than parallel state +machines. + +Use `validate_canonical_sequence_diagnostic` (or the normalization counterpart) +when recovery policy must distinguish intrinsically invalid input from +`CanonicalSequenceError::IncompleteRollback`. The latter exposes a stable +`CanonicalRollbackKind`, required ancestor, and oldest retained height; +`requires_history()` supports a simple retry-with-older-history branch without +parsing error prose. The original functions remain ergonomic wrappers returning +`ReactiveError`. Sequence validation covers canonical metadata, not network +binding: `CanonicalSequenceState` intentionally has no chain id, so a remote or +composite service must enforce one authoritative chain before sharing it. + +For a historical/live cutover, use +`normalize_and_validate_canonical_sequence`. It drops exact compatible older +progress, preserves an older barrier as the same id with `block: None`, and +retains equal-height progress/barriers that add missing parent or timestamp +metadata. Older enrichment is intentionally not applied when its regressive +control is not forwarded, keeping the extension state convergent with the +runtime. Unknown or conflicting overlap remains an error. + +Stage the returned state and mutations atomically and commit them only at the +same durable boundary as the source cursor/ACK. `CanonicalSequenceState` derives +serde for caller convenience, but its serialized Rust layout is **not** a +stable wire/checkpoint protocol. Persist it inside an application-owned, +versioned envelope with explicit migrations. Validation does not silently trim +history; call `retain_recent_history` after the matching cursor/ACK commits and +keep a horizon at least as deep as the deployment's supported reorg window. + ## Quick start ```rust,no_run @@ -559,10 +819,21 @@ deployments should opt into the strict/observable variants deliberately: - [ ] **Size reorg horizons deliberately.** `ReorgConfig::depth` and `ReactiveConfig::journal_depth` bound purge/rollback reach: a reorg *within* the journal is rolled back precisely, but effects from blocks that have already aged - out of the journal are **not** auto-purged. A reorg that deep escalates health - to `Unhealthy` (with a `warn!`) and leans on freshness validation as the - backstop — treat it as "resync before trusting sims" and size the horizons above - the deepest reorg you intend to recover precisely. + out of the journal are **not** auto-purged. The first incomplete recovery + degrades health and a repeated one escalates it to `Unhealthy`; freshness + validation is the backstop. Treat either as "resync before trusting sims" and size the horizons above + the deepest reorg you intend to recover precisely. Checkpointed ingestion + fails closed before applying or ACKing any explicit, implicit-parent, or + removed-log rollback outside the retained runtime journal; configure + `journal_depth` at least as deep as the event + source's advertised recovery window so production ingestion can continue. +- [ ] **Treat a replacement branch as a cache-coherency boundary.** Journaled + reactive effects and cached `BLOCKHASH` entries are rolled back or invalidated, + but ordinary account/storage values populated lazily by `SharedBackend` are + not tagged with the branch hash that produced them. If a reorg can change a + lazily fetched value that no handler owns, explicitly purge/resync the affected + account (or rebuild the cache) before trusting simulations on the replacement + branch. See `docs/KNOWN_ISSUES.md` for the distinction from journal depth. - [ ] **Know your provider.** The default bulk storage loader needs `eth_call` state-override support (major providers have it; the fetcher latches to point reads after two fully-failed batches — a `warn!` you should alert on, or diff --git a/RELEASING.md b/RELEASING.md index dc67ec9..4c6faa7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,44 +1,71 @@ # Releasing -`evm-fork-cache` is published after `alloy-transport-balancer` and before the -AMM state and search crates. The current 0.3.0 release intentionally uses a new -minor version because public configuration structs gained required fields. +`evm-fork-cache` 0.4.0-alpha.1 is the second prerelease in the Flashblocks +compatibility set. Publish `alloy-transport-balancer 0.3.0-alpha.1` first, then +publish this crate before any extension crate that declares +`evm-fork-cache = "0.4.0-alpha.1"`, including `evm-amm-state 0.3.0-alpha.1` and +the remote/Hybrid subscriber packages. +No release step is automatic: use clean, reviewed commits and never publish +from a credential-bearing working tree. ## Preflight ```bash cargo fmt --all -- --check -cargo test --all-targets --all-features -cargo test --doc --all-features -cargo clippy --all-targets --all-features --no-deps -- -D warnings -cargo clippy --all-targets --no-default-features --features reactive-polling --no-deps -- -D warnings -cargo test --no-default-features --features reactive-polling -cargo build --no-default-features -RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features -cargo bench --no-run --all-features -cargo bench --bench reactive_routing --features reactive -cargo +1.88 check --lib --locked -cargo package --locked +git diff --check +cargo test --locked --all-targets --all-features +cargo test --locked --doc --all-features +cargo clippy --locked --all-targets --all-features --no-deps -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --locked --all-features --no-deps +cargo check --locked --no-default-features +cargo check --locked --no-default-features --features reactive +cargo check --locked --no-default-features --features reactive-polling +cargo check --locked --no-default-features --features reactive-ws +cargo clippy --locked --all-targets --no-default-features --features reactive-polling --no-deps -- -D warnings +cargo test --locked --no-default-features --features reactive-polling +cargo +1.88.0 check --locked --lib +cargo bench --no-run --all-features --locked +bash scripts/check-security-exceptions.sh cargo audit --ignore RUSTSEC-2025-0055 +cargo package --locked ``` `RUSTSEC-2025-0055` is narrowly ignored because `ark-relations` records `tracing-subscriber 0.2.25` as an optional lockfile dependency while it remains -absent from `cargo tree --target all --all-features`. Remove the exception if -that version ever becomes active, or when the upstream metadata no longer -records it. +absent from `cargo tree --target all --all-features`. The scope script requires +that exact inactive lock entry, rejects any other locked vulnerable version, +and requires every active `tracing-subscriber` to be patched 0.3.20 or newer. +Remove the exception if 0.2.25 ever becomes active or disappears from the lock; +the disappearance intentionally fails the gate until the stale ignore is +removed. + +Confirm every third-party `uses:` entry remains pinned to the officially +verified full commit recorded in `SECURITY.md`, not a mutable tag or branch. +The stable and MSRV jobs must use the same pinned `dtolnay/rust-toolchain` +action with explicit `toolchain: stable` and `toolchain: 1.88.0` inputs. + +Inspect `cargo package --list --locked` and confirm that secrets, local databases, +planning/spec documents, and build output are excluded while consumer +documentation, tests, examples, and benchmarks needed to understand the public +surface are present. Run authenticated examples or probes only before this +clean-tree preflight, never as part of packaging. -Inspect `cargo package --list` and confirm that planning/spec documents remain -excluded while consumer documentation, tests, examples, and benchmarks needed -to understand the public surface are present. +Before publishing a durable subscriber extension, exercise a real multi-block +checkpoint restart through +`ReactiveEngine::preview_durable_resume_position`, the extension's asynchronous +preparation, and `restore_durable_checkpoint`. Do not substitute a manually +assembled one-block resume position; the test must prove the extension consumes +the core's retained canonical history exactly. ## Publish ```bash cargo publish --locked -git tag -s v0.3.0 -m "Release evm-fork-cache v0.3.0" -git push origin v0.3.0 +git tag -s v0.4.0-alpha.1 -m "Release evm-fork-cache v0.4.0-alpha.1" +git push origin v0.4.0-alpha.1 ``` -Wait for 0.3.0 to appear in the crates.io index before packaging -`evm-amm-state`. +Wait for 0.4.0-alpha.1 to appear in the crates.io index before removing sibling path +dependencies and verifying downstream extension packages. Publish only after +explicit authorization; preparing or running this checklist is not permission +to publish, tag, or push. diff --git a/SECURITY.md b/SECURITY.md index bb9a9de..bec318b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,3 +46,62 @@ These behaviors and their correct usage are described in [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md). Misuse of a documented escape hatch is a usage error, not a vulnerability; a way to violate a documented invariant *without* using an escape hatch is in scope. When in doubt, report it. + +Durable checkpoints are integrity-checked for accidental corruption, not +authenticated. Their directory must be writable only by the service identity, +and exactly one process may own a checkpoint path. Same-path writers are ordered +inside one process, but filesystem rename cannot coordinate independent +processes or defend an attacker-controlled parent directory. The final filename +is replaced as a directory entry (a destination symlink is not followed), and +atomic durable saves currently require Unix; unsupported targets fail closed +with `DurableCheckpointError::AtomicReplaceUnsupported`. + +## Dependency audit policy + +CI audits the locked dependency graph with RustSec. Before the audit, +`scripts/check-security-exceptions.sh` verifies that every accepted advisory or +unmaintained dependency remains inside the exact scope reviewed for this +release. A changed reverse-dependency path fails the build and requires a new +decision; an exception is never permission to ignore a newly reachable issue. + +The current graph has one ignored vulnerability advisory: + +- `RUSTSEC-2025-0055` affects `tracing-subscriber` 0.2.25. That version is an + unreachable lockfile entry. Because cargo-audit ignores the advisory by ID, + the scope script checks every locked and active `tracing-subscriber` version: + 0.2.25 must remain the only vulnerable lock entry and stay unreachable, while + every all-feature/all-target active version must be patched 0.3.20 or newer. + The gate also fails if 0.2.25 disappears so the now-stale ignore must be + removed. + +RustSec also reports three unmaintained crates. They are not vulnerability +advisories, but their disposition is checked on every release: + +- `bincode` 1.3.3 remains a direct dependency because the crate's already + versioned binary cache formats use its encoding. Replacing it requires an + explicit format migration rather than silently making existing caches + unreadable. No additional package may acquire this dependency under this + acceptance. +- `derivative` 2.2.0 is an unreachable lockfile entry and is accepted only + while it remains unreachable. +- `paste` 1.0.15 is an active transitive procedural macro through the pinned + Alloy/Arkworks graph. It is not called by this crate at runtime. Its immediate + reverse-dependency set is pinned by the scope check while upstream migration + is tracked. + +Run `scripts/check-security-exceptions.sh` and `cargo audit --ignore +RUSTSEC-2025-0055` before every release. Remove an exception as soon as its +locked entry or upstream constraint disappears. + +Every third-party CI action is pinned to an immutable full commit SHA. Adjacent +comments retain the reviewed human-readable upstream ref: + +| Action | Reviewed ref | Pinned commit | +| --- | --- | --- | +| [`actions/checkout`](https://github.com/actions/checkout/releases/tag/v4.4.0) | `v4.4.0` | `11d5960a326750d5838078e36cf38b85af677262` | +| [`dtolnay/rust-toolchain`](https://github.com/dtolnay/rust-toolchain/commit/4cda84d5c5c54efe2404f9d843567869ab1699d4) | `stable` | `4cda84d5c5c54efe2404f9d843567869ab1699d4` | +| [`Swatinem/rust-cache`](https://github.com/Swatinem/rust-cache/releases/tag/v2.9.1) | `v2.9.1` | `c19371144df3bb44fab255c43d04cbc2ab54d1c4` | + +The stable and MSRV jobs use the same reviewed toolchain-action commit and pass +their requested toolchain explicitly. Updating any action requires verifying +the new upstream ref and full commit before changing the pin. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 31640cc..10cc8c5 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -22,10 +22,12 @@ Confidence legend: **[V]** verified against the source during review; "no block pin" state; explicit construction uses `EvmCache::at_block(provider, block)`. `set_block` takes a concrete `BlockId`, sets `block_number` only for numeric pins, and clears it for - tag/hash pins. Every block change clears stale `basefee`; callers refresh - `NUMBER`/`BASEFEE` together with `set_block_context` after fetching the new - header. Freshness validation captures the cache's concrete snapshot pin and - passes it through to storage fetchers. + tag/hash pins. Every repin clears `NUMBER`, `BASEFEE`, `COINBASE`, + `PREVRANDAO`, `GASLIMIT`, and timestamp provenance so values from the old + header cannot leak into the new pin. Callers must reinstall any intentional + manual overrides, or use `advance_block` with a complete canonical header. + Freshness validation captures the cache's concrete snapshot pin and passes it + through to storage fetchers. 3. **[FIXED] Synchronous layer-2 escape hatches have an invalidating wrapper.** Raw handles are now visibly named `unchecked_blockchain_db()` / @@ -106,6 +108,19 @@ surface was moved out of this crate. ## Limitations by design / roadmap +- **Preconfirmed branch replacement discards lazy reads made after branch + capture.** The Flashblocks runtime takes a complete canonical cache snapshot + before applying the first preconfirmed payload and restores that snapshot + when the speculative branch is replaced or discarded. This is deliberately + fail-safe for correctness, but it also removes unrelated account/storage + values fetched lazily by simulations while the branch was active. Repeated + quotes can therefore pay the same provider round trip again on later + Flashblocks even when their read set is unchanged. Cumulative updates within + one payload keep the active branch and retain those reads. The alpha accepts + this performance limitation; production rollout is gated on canonical + read-set priming plus selective speculative rollback (or an equivalent + persistent warm layer), provider-read-count regression coverage, and a repeat + live latency benchmark. - **Storage-only freshness verification; `ConfirmedFull` is defined but not yet emitted.** The optimistic verify-and-rerun loop builds its verify set from the volatile storage *slots* in each sim's read set, and its success verdict says @@ -180,8 +195,35 @@ surface was moved out of this crate. the freshness/validation loop is the backstop for that span. This is not silent: the runtime emits a `tracing::warn!` when a reorg references a block no longer in the journal. Set `journal_depth` above the deepest reorg you intend to recover - precisely. (The full conservative-purge fallback for aged-out blocks is a tracked - follow-up, not a known defect.) + precisely. The crash-safe `ReactiveEngine::*_checkpointed` paths are stricter: + an explicit reorg, implicit-parent replacement, or removed/reorged record whose + required rollback proof falls outside the retained effect journal is rejected + before cache mutation, durable save, or source ACK rather than checkpointing + partial recovery. Configure the runtime depth at least as large as the + subscriber's promised reorg window. + Ordinary direct/non-checkpointed ingestion retains the degraded partial- + recovery behavior above for compatibility. (The full conservative-purge + fallback for aged-out blocks is a tracked follow-up, not a known defect.) +- **Replacement-branch rollback does not provenance-tag every lazy account or + storage read.** In-window reactive handler effects are journaled and rolled + back/purged, and displaced `BLOCKHASH` cache entries are now explicitly + invalidated. Ordinary account/storage values fetched lazily through + `SharedBackend`, however, are cached by address/slot rather than by the + canonical block hash that supplied them. Re-pinning to a replacement branch + cannot identify which of those values changed on that branch. This is + separate from the `journal_depth` limit: it can matter even for a shallow + reorg when a lazily read value is not maintained by a handler. Production + consumers should event-maintain and root-gate the state they rely on, or + explicitly purge/resync affected accounts (or reconstruct the cache) before + trusting replacement-branch simulations. +- **`ChainControl::CanonicalProgress` certifies event coverage, not full-header + readiness.** Compact progress and block-bearing barriers exact-hash pin lazy + provider reads and install known `NUMBER`/timestamp metadata. They deliberately + clear `BASEFEE`, `COINBASE`, `PREVRANDAO`, and `GASLIMIT` unless a full header + for that exact number/hash was already verified. This is safe for event-state + catch-up, but a consumer whose simulation reads those opcodes must wait for or + fetch the full canonical header before treating the cache as EVM-environment + ready. - **Bundle `coinbase_payment` excludes the gas of `AllowReverts` transactions that actually revert.** `simulate_bundle` rolls a reverting whitelisted tx back to its inner checkpoint, which also undoes the gas that tx charged to the beneficiary. So @@ -248,11 +290,18 @@ surface was moved out of this crate. remaining transport limits: **full block bodies**, **full pending-transaction hydration**, and non-log historical backfill are not implemented (the subscriber returns a typed `SubscriberError::Unsupported` for non-hash pending - interests). Mid-lifecycle handler registration through `ReactiveEngine` - backfills a new handler's logs from the runtime's last canonical block - automatically and catches the newly connected stream up from that anchor after - it subscribes, so the discovery→subscription window is closed without caller - bookkeeping; the bounded `dedupe_window` suppresses the overlap. The residual + interests). Alloy log catch-up is intended for bounded live gaps: each + filter/window uses one complete-range `eth_getLogs` request. The + `max_backfill_log_bytes` limit rejects a response after decoding, but ranges + are not adaptively split and provider result caps can fail a dense or deep + query first. Keep registration and reconnect windows modest; use HyperSync or + another indexing `EventSubscriber` for deep/high-density history. + Mid-lifecycle handler registration through `ReactiveEngine` adopts the live + desired state first, replays the new owner at the retained canonical block, + then catches the complete handler union up globally above that block through + activation. This closes the discovery→subscription window without leaving + later effects outside the global rollback journal; the bounded `dedupe_window` + suppresses overlap. The residual limit is a genuinely live-only registration (no anchor and no backfill requested): logs between the registration call and the live subscription start are not fetched. A reconnect after more than `dedupe_window` matching logs can @@ -266,9 +315,10 @@ surface was moved out of this crate. end-to-end is now covered offline in `tests/reactive_subscriber_ingest.rs` (a real subscriber batch, produced via the mockable `get_logs` backfill path, drives a real runtime ingest and asserts the cache write). The remaining paths - without dedicated integration coverage are the block-header ingest path, the - `ReactiveReport::Decoded` shape, the `EventDecoderHandler` adapter, and custom - pending-tx matcher/route-key routing; the live WebSocket transport plumbing is + without dedicated integration coverage are the `EventDecoderHandler` adapter + and custom pending-tx matcher/route-key routing. Block-header ingestion is + covered in `tests/block_context.rs`, and decoded-report delivery is asserted + in `tests/reactive_engine.rs`. The live WebSocket transport plumbing is covered by reconnect/termination unit tests but not by a networked end-to-end test. These are tracked follow-ups, not known defects. - **Recent toolchain.** MSRV 1.88 and edition 2024 are intentional and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d5185b5..98a5e5d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,7 +3,7 @@ > Status: living document. Phases 0-8 have landed: through bundle simulation + > call tracing, plus Phase 8 — storageHash liveness & state invalidation — > which shipped in **0.2.0** (all six steps of -> [`phase-8-liveness-spec.md`](phase-8-liveness-spec.md), including the +> [`phase-8-liveness-spec.md`](https://github.com/KaiCode2/evm-fork-cache/blob/main/docs/phase-8-liveness-spec.md), including the > cold-start root baseline and the Tier-3 trace-backed resync source). ## Vision @@ -92,7 +92,7 @@ RPC node Event-driven sync ← WS logs · new block | **5** | COW snapshots (Pillar A): structural sharing; overlay buffer reuse. | **Done** (`phase-5-cow-snapshots`) | | **6** | Reactive runtime + live transport: provider-neutral `ReactiveRuntime` / `ReactiveHandler`, journaled depth-bounded reorg recovery, the WebSocket `AlloySubscriber`, and declarative `cold_start` warming. | **Done** (`cold-start-sync`) | | **7** | Bundle simulation + call tracing: `EvmOverlay::simulate_bundle` (ordered cumulative-state txs, `RevertPolicy`, coinbase-payment accounting) and a `CallTracer` (call-frame tree) + composable `InspectorStack`. | **Done** (`phase-6-bundle-sim`) | -| **8** | storageHash liveness & state invalidation (Pillar C): account/root fetcher seam (`eth_getProof`); per-block root gate + complement resync (`ResyncReason::RootMoved`) + coverage alarm; per-contract `TrackingPolicy` (`Slots`/`WholeAccount`/`Scalars`); event-write `Validity` stamping; `advance_block` block-env refresh; cold-start root baseline (`roots.bin`); Tier-3 trace-backed resync. | **Done in 0.2.0** ([spec](phase-8-liveness-spec.md)) | +| **8** | storageHash liveness & state invalidation (Pillar C): account/root fetcher seam (`eth_getProof`); per-block root gate + complement resync (`ResyncReason::RootMoved`) + coverage alarm; per-contract `TrackingPolicy` (`Slots`/`WholeAccount`/`Scalars`); event-write `Validity` stamping; `advance_block` block-env refresh; cold-start root baseline (`roots.bin`); Tier-3 trace-backed resync. | **Done in 0.2.0** ([spec](https://github.com/KaiCode2/evm-fork-cache/blob/main/docs/phase-8-liveness-spec.md)) | Cross-cutting remaining work: `Create`-kind / state-override bundles, opcode-level tracing, and a full no-provider build split. @@ -116,8 +116,8 @@ a 1.0. - **Files:** `src/errors.rs` (+ `thiserror` dep), call sites in `src/cache/mod.rs` / `src/cache/overlay.rs`. - **API:** `enum SimError { Revert(Box), Halt { .. }, Other(SimHostError) }`; - `type SimulationResult = Result`. `SimulationErrorKind` - retained as a deprecated alias. + `type SimulationResult = Result`. The pre-release + `SimulationErrorKind` alias was removed before the public surface shipped. - **Done when:** halts surface typed; `cargo test` + clippy green. ### 1b — Configurable transaction & block environment @@ -545,14 +545,16 @@ local trie and no proof verification**. This phase uses it to detect and repair the staleness the current footprint-bounded model cannot see. Full design, type sketches, tests, and the cold-start correctness argument live in -[`phase-8-liveness-spec.md`](phase-8-liveness-spec.md). The build set (in order): +[`phase-8-liveness-spec.md`](https://github.com/KaiCode2/evm-fork-cache/blob/main/docs/phase-8-liveness-spec.md). The build set (in order): 1. **Account/root fetcher seam** on `EvmCache` (`AccountProofFetchFn` over `eth_getProof`, mirroring `StorageBatchFetchFn`). The linchpin — it also resolves the tracked `ResyncTarget::Account` `Unsupported` gap (`reactive/mod.rs`) and the account-field freshness gap. 2. **`advance_block(header)`** — engine-driven block-env refresh from the - canonical header stream (the runtime does not refresh scalars per block today). + canonical header stream. Runtime `BlockHeader` and `FullBlock` ingestion now + use this path; compact `CanonicalProgress` intentionally proves only coverage + and clears header fields it cannot authenticate. 3. **`Validity` stamping** of reactive/event-derived writes — the first (minimal, intentional) coupling of the reactive runtime to `FreshnessRegistry` (`valid_through_slot(N)` on touched slots, aged by `on_new_block`). @@ -606,7 +608,16 @@ acceptance contract in the spec (`tests/liveness_*`). ## Remaining work toward 1.0 -1. **Bundle-simulation breadth (Phase 7 — core shipped).** `EvmOverlay::simulate_bundle` +1. **Preconfirmation read-set retention (production-rollout gate).** Prime + representative simulation read sets before subscriber attachment, then + replace whole-cache preconfirmation restore with target-scoped rollback or + an equivalent persistent canonical warm layer. Speculative account, slot, + balance, resync, and purge effects must still be removed exactly, while + unrelated lazy fills survive branch replacement. Acceptance requires + provider-read-count tests across cumulative/replaced/discarded payloads and a + repeat paid-provider benchmark with no recurring RPC-scale quote-latency + mode. +2. **Bundle-simulation breadth (Phase 7 — core shipped).** `EvmOverlay::simulate_bundle` now evaluates an ordered tx sequence over cumulative state with a revert policy and coinbase-payment accounting, and a `CallTracer` reconstructs the call-frame tree (see Phase 7 below). The remaining breadth: `Create`-kind bundle txs, a @@ -614,17 +625,17 @@ acceptance contract in the spec (`tests/liveness_*`). reverted-tx gas accounting under `AllowReverts` (a reverted tx's gas is rolled back with its checkpoint, so it is not counted toward the searcher's cost — tracked in `docs/KNOWN_ISSUES.md`). -2. **Transport depth.** The live `AlloySubscriber` ships log/block/pending-hash +3. **Transport depth.** The live `AlloySubscriber` ships log/block/pending-hash subscriptions, exponential-backoff reconnect, `get_logs` backfill, and journaled parent-hash reorg recovery. The remaining transport gaps are full block bodies, full pending-transaction hydration (today only pending-tx hashes), and non-log historical backfill. Log interests can request owner-scoped `get_logs` backfill from a block anchor. Remaining gaps are tracked in `docs/KNOWN_ISSUES.md`. -3. **Snapshot consistency point in continuous ingestion.** Closed in 0.2.0: +4. **Snapshot consistency point in continuous ingestion.** Closed in 0.2.0: `EvmCache::snapshot_generation()` is the crate-provided generation guard — read it around `snapshot()` and re-snapshot when it moved, so simulations never observe a partially applied block (G6). -4. **Full no-provider build split.** The dependency graph still includes +5. **Full no-provider build split.** The dependency graph still includes provider/RPC crates. A later `rpc` feature can make those optional for pure offline users. diff --git a/docs/phase-6-bundle-sim-spec.md b/docs/phase-6-bundle-sim-spec.md index 844ce4f..21590f3 100644 --- a/docs/phase-6-bundle-sim-spec.md +++ b/docs/phase-6-bundle-sim-spec.md @@ -266,5 +266,5 @@ impl Inspector for InspectorStack where … { /* fa `cargo fmt --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test --all-features`, doctests, `RUSTDOCFLAGS=-D warnings cargo doc --no-deps`. -Manager-authored acceptance tests: `tests/bundle_simulation.rs` (A+B), +Acceptance tests: `tests/bundle_simulation.rs` (A+B), `tests/call_tracer.rs` (C). No new production dependencies. Offline only (mocked provider). diff --git a/examples/reactive_alloy_amm_live_probe.rs b/examples/reactive_alloy_amm_live_probe.rs index cb883c1..b1af2fa 100644 --- a/examples/reactive_alloy_amm_live_probe.rs +++ b/examples/reactive_alloy_amm_live_probe.rs @@ -196,7 +196,7 @@ where }) }) .collect::>(); - subscriber.register_interests(&interests)?; + subscriber.register_interests(&interests).await?; let started = Instant::now(); let run_for = Duration::from_secs(run_seconds); diff --git a/examples/reactive_engine_lifecycle.rs b/examples/reactive_engine_lifecycle.rs index 11aef4a..a1a9234 100644 --- a/examples/reactive_engine_lifecycle.rs +++ b/examples/reactive_engine_lifecycle.rs @@ -14,9 +14,11 @@ //! 2. Ingest a canonical block. The runtime now has a canonical head. //! 3. Discover a new pool mid-stream and [`register_handler`](ReactiveEngine::register_handler) //! it. Because the runtime has a canonical head, the new handler is -//! **backfilled from that block automatically** — the discovery→subscription -//! window closes with no caller bookkeeping. (`register_handler_with_backfill` -//! for deeper history; `register_handler_live_only` to opt out.) +//! live-adopted, replayed owner-only at that retained block, and globally +//! caught up above it through activation — the discovery→subscription window +//! closes with no caller bookkeeping. (`register_handler_with_backfill` for +//! an explicit replay of one retained block only; +//! `register_handler_live_only` to opt out.) //! 4. Retire a pool with the teardown recipe: //! [`unregister_handler`](ReactiveEngine::unregister_handler) (routing + //! transport) plus [`untrack_account`](ReactiveRuntime::untrack_account) (stop @@ -25,7 +27,8 @@ //! queued repairs). Cache eviction stays an explicit caller action. //! //! In production the scripted subscriber is replaced by `AlloySubscriber` (which -//! implements [`InterestOwnerSubscriber`]); the engine calls are identical. +//! implements [`InterestOwnerSubscriber`]); the awaited engine calls are +//! identical. //! //! Runs fully offline against a mocked provider — no network, no RPC key. //! @@ -52,7 +55,7 @@ use evm_fork_cache::reactive::{ InterestOwnerSubscriber, LogInterest, ReactiveConfig, ReactiveContext, ReactiveEffect, ReactiveEngine, ReactiveHandler, ReactiveInput, ReactiveInputBatch, ReactiveInputRecord, ReactiveInterest, ReactiveRuntime, RouteKeySpec, StateEffectQuality, SubscriberBackfill, - SubscriberError, SubscriberNextBatch, TrackingPolicy, + SubscriberNextBatch, SubscriberOperation, TrackingPolicy, }; /// The storage slot each pool handler maintains from its swap logs (a stand-in @@ -119,10 +122,12 @@ impl EventSubscriber for ScriptedSubscriber { fn register_interests( &mut self, _interests: &[ReactiveInterest], - ) -> Result<(), SubscriberError> { - // Full-replacement setup path — unused here; the engine drives owners. - self.owners.clear(); - Ok(()) + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + // Full-replacement setup path — unused here; the engine drives owners. + self.owners.clear(); + Ok(()) + }) } fn next_batch(&mut self) -> SubscriberNextBatch<'_, Ethereum> { @@ -135,9 +140,12 @@ impl InterestOwnerSubscriber for ScriptedSubscriber { &mut self, owner: HandlerId, interests: &[ReactiveInterest], - ) -> Result<(), SubscriberError> { - self.owners.insert(owner, interests.to_vec()); - Ok(()) + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + self.owners.insert(owner, interests); + Ok(()) + }) } fn add_interest_owner_with_backfill( @@ -145,17 +153,42 @@ impl InterestOwnerSubscriber for ScriptedSubscriber { owner: HandlerId, interests: &[ReactiveInterest], backfill: SubscriberBackfill, - ) -> Result<(), SubscriberError> { - self.add_interest_owner(owner.clone(), interests)?; - self.backfills.push((owner, backfill)); - Ok(()) + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + self.owners.insert(owner.clone(), interests); + self.backfills.push((owner, backfill)); + Ok(()) + }) + } + + fn add_interest_owner_with_canonical_catchup( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + retained: BlockRef, + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + // This deterministic source does not advance while registration is in + // flight, so activation is still at C: owner catch-up covers exactly C + // and the required global C+1..activation interval is empty. A live + // implementation subscribes first, fetches that owner-only C slice, + // then globally catches every active interest up to its activation head. + let catchup = SubscriberBackfill::from_canonical_block_through(retained, retained.number); + Box::pin(async move { + let catchup = catchup?; + self.owners.insert(owner.clone(), interests); + self.backfills.push((owner, catchup)); + Ok(()) + }) } fn remove_interest_owner( &mut self, owner: &HandlerId, - ) -> Option>> { - self.owners.remove(owner) + ) -> SubscriberOperation<'_, Option>>> { + let owner = owner.clone(); + Box::pin(async move { Ok(self.owners.remove(&owner)) }) } fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest]> { @@ -190,7 +223,7 @@ fn swap_batch(pool: Address, block_number: u64, value: u64) -> ReactiveInputBatc chain_id: Some(1), source: InputSource::Subscription, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -214,10 +247,12 @@ async fn main() -> Result<()> { // 1. Register the first pool on a fresh runtime → live-only (no canonical // head to backfill from yet). - engine.register_handler(Arc::new(PoolHandler { - id: HandlerId::new("pool-a"), - pool: pool_a, - }))?; + engine + .register_handler(Arc::new(PoolHandler { + id: HandlerId::new("pool-a"), + pool: pool_a, + })) + .await?; // Track pool-A so the root gate re-verifies its storage root on a cadence. engine .runtime_mut() @@ -247,12 +282,14 @@ async fn main() -> Result<()> { ); // 3. A PoolCreated event surfaces pool-B mid-stream. Registering it now - // auto-anchors its log backfill to the runtime's canonical head — no + // auto-anchors coordinated catch-up to the runtime's canonical head — no // caller bookkeeping, no discovery→subscription gap. - engine.register_handler(Arc::new(PoolHandler { - id: HandlerId::new("pool-b"), - pool: pool_b, - }))?; + engine + .register_handler(Arc::new(PoolHandler { + id: HandlerId::new("pool-b"), + pool: pool_b, + })) + .await?; let (owner, backfill) = engine .subscriber() .backfills @@ -282,7 +319,7 @@ async fn main() -> Result<()> { // root-gate probes, and drop any queued repairs. Cache eviction (if you // want the state gone) stays an explicit `StateUpdate::purge` / cache API // call — deliberately not implied by unregistration. - let removed = engine.unregister_handler(&HandlerId::new("pool-a")); + let removed = engine.unregister_handler(&HandlerId::new("pool-a")).await?; let untracked = engine.runtime_mut().untrack_account(pool_a); let cancelled = engine.runtime_mut().cancel_pending_resyncs(pool_a); println!( diff --git a/examples/reactive_runtime.rs b/examples/reactive_runtime.rs index 440a189..02a3d94 100644 --- a/examples/reactive_runtime.rs +++ b/examples/reactive_runtime.rs @@ -182,7 +182,7 @@ fn included(block: BlockRef, log_index: u64) -> ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -197,7 +197,7 @@ fn reorged(dropped: BlockRef, log_index: u64) -> ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Reorged { - dropped_from: dropped.clone(), + dropped_from: dropped, }, block: Some(dropped), transaction_index: Some(0), @@ -263,7 +263,7 @@ async fn main() -> Result<()> { 0, false, )), - included(canonical.clone(), 0), + included(canonical, 0), ), )?; println!("\n=== block {} ingested ===", canonical.number); @@ -291,7 +291,7 @@ async fn main() -> Result<()> { 0, true, )), - reorged(canonical.clone(), 0), + reorged(canonical, 0), ), )?; let reorg = report diff --git a/scripts/check-security-exceptions.sh b/scripts/check-security-exceptions.sh new file mode 100755 index 0000000..b8b81d2 --- /dev/null +++ b/scripts/check-security-exceptions.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +set +x +set -euo pipefail + +cd "$(dirname "$0")/.." + +tracing_policy_error="" + +is_patched_tracing_subscriber_version() { + local version="$1" + if [[ ! "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + return 1 + fi + + local major="${BASH_REMATCH[1]}" + local minor="${BASH_REMATCH[2]}" + local patch="${BASH_REMATCH[3]}" + ((major > 0 || minor > 3 || (minor == 3 && patch >= 20))) +} + +validate_tracing_subscriber_policy() { + local locked_versions="$1" + local active_versions="$2" + local version + local ignored_lock_entries=0 + local active_entries=0 + tracing_policy_error="" + + while IFS= read -r version; do + [[ -n "$version" ]] || continue + if [[ "$version" == "0.2.25" ]]; then + ((ignored_lock_entries += 1)) + elif ! is_patched_tracing_subscriber_version "$version"; then + tracing_policy_error="Cargo.lock contains another tracing-subscriber version covered by RUSTSEC-2025-0055" + return 1 + fi + done <<<"$locked_versions" + + if ((ignored_lock_entries != 1)); then + tracing_policy_error="Cargo.lock must contain exactly one tracing-subscriber 0.2.25 entry until the advisory ignore is removed" + return 1 + fi + + while IFS= read -r version; do + [[ -n "$version" ]] || continue + ((active_entries += 1)) + if ! is_patched_tracing_subscriber_version "$version"; then + tracing_policy_error="the active graph contains tracing-subscriber below patched version 0.3.20" + return 1 + fi + done <<<"$active_versions" + + if ((active_entries == 0)); then + tracing_policy_error="the active tracing-subscriber version set is unexpectedly empty" + return 1 + fi +} + +assert_tracing_policy_fixtures() { + local valid_locked + valid_locked="$(printf '%s\n' '0.2.25' '0.3.23')" + + if ! validate_tracing_subscriber_policy "$valid_locked" "0.3.23"; then + echo "Internal tracing-subscriber policy fixture rejected the reviewed shape." >&2 + exit 1 + fi + if validate_tracing_subscriber_policy "0.3.23" "0.3.23"; then + echo "Internal tracing-subscriber policy fixture accepted a stale advisory ignore." >&2 + exit 1 + fi + if validate_tracing_subscriber_policy \ + "$(printf '%s\n' '0.2.25' '0.3.19' '0.3.23')" "0.3.23" + then + echo "Internal tracing-subscriber policy fixture accepted another vulnerable lock entry." >&2 + exit 1 + fi + if validate_tracing_subscriber_policy "$valid_locked" "0.2.25"; then + echo "Internal tracing-subscriber policy fixture accepted an active vulnerable version." >&2 + exit 1 + fi + if validate_tracing_subscriber_policy "$valid_locked" ""; then + echo "Internal tracing-subscriber policy fixture accepted an empty active set." >&2 + exit 1 + fi +} + +assert_tracing_policy_fixtures + +# RUSTSEC-2025-0055 is advisory-wide, so prove the complete active version set +# is patched and that 0.2.25 is the only vulnerable locked version. Requiring +# the exact inactive entry to remain makes its removal fail closed: the ignore +# must be deleted instead of silently becoming stale. +locked_tracing_versions="$( + awk ' + /^\[\[package\]\]$/ { + if (name == "tracing-subscriber") { + print version + } + name = "" + version = "" + next + } + /^name = / { + value = $0 + sub(/^name = "/, "", value) + sub(/"$/, "", value) + name = value + next + } + /^version = / { + value = $0 + sub(/^version = "/, "", value) + sub(/"$/, "", value) + version = value + next + } + END { + if (name == "tracing-subscriber") { + print version + } + } + ' Cargo.lock | sort +)" +active_dependency_tree="$( + cargo tree --locked --all-features --target all --prefix none +)" +active_tracing_versions="$( + printf '%s\n' "$active_dependency_tree" \ + | awk '$1 == "tracing-subscriber" { sub(/^v/, "", $2); print $2 }' \ + | sort -u +)" + +if ! validate_tracing_subscriber_policy \ + "$locked_tracing_versions" "$active_tracing_versions" +then + echo "RUSTSEC-2025-0055 scope check failed: $tracing_policy_error." >&2 + echo "Locked tracing-subscriber versions:" >&2 + printf '%s\n' "$locked_tracing_versions" >&2 + echo "Active tracing-subscriber versions:" >&2 + printf '%s\n' "$active_tracing_versions" >&2 + exit 1 +fi + +inactive_tracing="$({ + cargo tree --locked --all-features \ + -i tracing-subscriber@0.2.25 --target all --prefix none 2>/dev/null +} || true)" +if [[ -n "$inactive_tracing" ]]; then + echo "RUSTSEC-2025-0055 is no longer confined to an inactive lock entry." >&2 + echo "$inactive_tracing" >&2 + exit 1 +fi + +# bincode 1 is retained deliberately for compatibility with the crate's +# versioned on-disk cache formats. It must remain a direct dependency of this +# crate only; a new consumer requires a fresh migration/security decision. +bincode_graph="$({ + cargo tree --locked -i bincode@1.3.3 --target all --prefix depth +} | sed -E 's# \(/[^)]*\)$##; s# \(\*\)$##')" +expected_bincode_graph="$(printf '%s\n' \ + '0bincode v1.3.3' \ + '1evm-fork-cache v0.4.0-alpha.1')" +if [[ "$bincode_graph" != "$expected_bincode_graph" ]]; then + echo "The accepted bincode 1 compatibility scope changed." >&2 + echo "Expected:" >&2 + echo "$expected_bincode_graph" >&2 + echo "Observed:" >&2 + echo "$bincode_graph" >&2 + exit 1 +fi + +# derivative is tolerated only as an unreachable lockfile entry. +inactive_derivative="$({ + cargo tree --locked -i derivative@2.2.0 --target all --prefix none 2>/dev/null +} || true)" +if [[ -n "$inactive_derivative" ]]; then + echo "Unmaintained derivative 2.2.0 became reachable." >&2 + echo "$inactive_derivative" >&2 + exit 1 +fi + +# paste is an active transitive procedural macro. Keep its immediate reverse +# dependency set pinned so a new path cannot inherit this release decision. +paste_graph="$({ + cargo tree --locked -i paste@1.0.15 --target all --prefix depth --depth 1 +} | sed -E 's# \(/[^)]*\)$##; s# \(\*\)$##')" +expected_paste_graph="$(printf '%s\n' \ + '0paste v1.0.15 (proc-macro)' \ + '1alloy-primitives v1.6.1' \ + '1ark-ff v0.5.0' \ + '1syn-solidity v1.6.1')" +if [[ "$paste_graph" != "$expected_paste_graph" ]]; then + echo "The accepted paste dependency scope changed." >&2 + echo "Expected:" >&2 + echo "$expected_paste_graph" >&2 + echo "Observed:" >&2 + echo "$paste_graph" >&2 + exit 1 +fi + +echo "Security advisory and unmaintained-dependency scopes match policy." diff --git a/src/cache/durable_checkpoint.rs b/src/cache/durable_checkpoint.rs new file mode 100644 index 0000000..a9d2e5a --- /dev/null +++ b/src/cache/durable_checkpoint.rs @@ -0,0 +1,1186 @@ +//! Atomic, versioned checkpoints for event-maintained EVM state. +//! +//! The ordinary cache files are startup accelerators and may be flushed +//! independently. A durable checkpoint has a stricter contract: cache state, +//! canonical chain position, consumer identity, handler schema, and the last +//! ingested subscriber token are serialized into one file and atomically +//! replaced before the token may be acknowledged upstream. Files carry a +//! Keccak integrity checksum and a configurable resource bound. The checksum +//! detects accidental corruption; it is not authentication for an +//! attacker-writable checkpoint path. + +use std::{ + collections::HashMap, + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Component, Path, PathBuf}, + sync::{ + Arc, Mutex, OnceLock, Weak, + atomic::{AtomicU64, Ordering}, + }, +}; + +use alloy_eips::{BlockId, BlockNumberOrTag, RpcBlockHash}; +use alloy_primitives::{Address, B256, U256, keccak256}; +use foundry_fork_db::BlockchainDb; +use revm::{database::Cache, primitives::hardfork::SpecId, state::AccountInfo}; +use serde::{Deserialize, Serialize}; + +use super::{ + BlockEnvSource, CodeSeedState, EvmCache, ImmutableDataCache, TrackedMapping, versioned, +}; + +const CHECKPOINT_MAGIC: &[u8; 8] = b"EFCCKPT\0"; +const CHECKPOINT_VERSION: u32 = 6; +const CHECKPOINT_LABEL: &str = "durable reactive checkpoint"; +const CHECKPOINT_CHECKSUM_BYTES: usize = 32; +const CHECKPOINT_HEADER_BYTES: u64 = + CHECKPOINT_MAGIC.len() as u64 + std::mem::size_of::() as u64; +const MAX_TEMP_CREATE_ATTEMPTS: usize = 128; +/// Default upper bound for a single durable checkpoint file (512 MiB). +pub const DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES: u64 = 512 * 1024 * 1024; +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); +static CHECKPOINT_COORDINATORS: OnceLock< + Mutex>>, +> = OnceLock::new(); + +/// Stable identity of one durable cache consumer. +/// +/// `subscriber_id` distinguishes independently acknowledged event sessions. +/// `handler_set_id` is an application-owned schema/version fingerprint; change +/// it whenever handler decoding or state semantics become incompatible with an +/// older checkpoint. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct DurableCheckpointIdentity { + /// Chain whose state is represented by the checkpoint. + pub chain_id: u64, + /// Stable event-subscriber or remote-session identity. + pub subscriber_id: String, + /// Stable application handler-set/schema identity. + pub handler_set_id: String, +} + +impl DurableCheckpointIdentity { + /// Construct a durable consumer identity. + pub fn new( + chain_id: u64, + subscriber_id: impl Into, + handler_set_id: impl Into, + ) -> Self { + Self { + chain_id, + subscriber_id: subscriber_id.into(), + handler_set_id: handler_set_id.into(), + } + } +} + +/// Canonical block committed by a durable checkpoint. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct DurableCheckpointBlock { + /// Block number. + pub number: u64, + /// Canonical block hash. Applications must validate this against their RPC + /// source before restoring a non-finalized checkpoint. + pub hash: B256, + /// Parent hash, when the source supplied it. + pub parent_hash: Option, + /// Block timestamp, when the source supplied it. + pub timestamp: Option, +} + +impl DurableCheckpointBlock { + /// Construct the minimum exact canonical identity required for restore. + pub const fn new(number: u64, hash: B256) -> Self { + Self { + number, + hash, + parent_hash: None, + timestamp: None, + } + } + + /// Attach the canonical parent hash supplied by the source. + pub const fn with_parent_hash(mut self, parent_hash: B256) -> Self { + self.parent_hash = Some(parent_hash); + self + } + + /// Attach the block timestamp supplied by the source. + pub const fn with_timestamp(mut self, timestamp: u64) -> Self { + self.timestamp = Some(timestamp); + self + } +} + +/// Public commit metadata stored alongside the cache snapshot. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct DurableCheckpointMetadata { + /// Durable consumer identity guarded on restore. + pub identity: DurableCheckpointIdentity, + /// Last canonical block whose event effects are included. + pub block: DurableCheckpointBlock, + /// Last subscriber delivery token included in the snapshot. + /// + /// If the subscriber replays this token after an acknowledgement was lost, + /// the consumer can acknowledge it without applying the batch again. + pub delivery_token: Option>, + /// Core-computed witness for the exact delivery associated with + /// [`delivery_token`](Self::delivery_token). + /// + /// The witness is intentionally retained with its token when a later + /// tokenless barrier advances the overall checkpoint. On replay, the engine + /// requires the incoming delivery to reproduce this witness before it can + /// acknowledge the token without reapplying the batch. A token supplied by + /// low-level callers without a witness cannot use that replay shortcut. + pub delivery_witness: Option, + /// Opaque provider-specific resume state committed with this snapshot. + /// + /// The cache never interprets this value. A subscriber extension may use it + /// to resume from a native cursor after process restart. + pub subscriber_checkpoint: Option>, + /// Opaque core-runtime recovery state committed with the cache snapshot. + /// + /// The cache layer stores these bytes but does not interpret them. The + /// reactive engine uses them to restore finality and its bounded rollback + /// journal after restart. + pub runtime_checkpoint: Option>, +} + +impl DurableCheckpointMetadata { + /// Construct checkpoint metadata. + pub fn new(identity: DurableCheckpointIdentity, block: DurableCheckpointBlock) -> Self { + Self { + identity, + block, + delivery_token: None, + delivery_witness: None, + subscriber_checkpoint: None, + runtime_checkpoint: None, + } + } + + /// Attach the subscriber token whose effects are represented by this state. + pub fn with_delivery_token(mut self, delivery_token: impl Into>) -> Self { + self.delivery_token = Some(delivery_token.into()); + self + } + + /// Attach the core delivery witness associated with the delivery token. + /// + /// Most applications should let the reactive engine compute this value. + /// This builder exists for checkpoint migration and other + /// low-level integrations that reproduce the core witness contract exactly. + pub fn with_delivery_witness(mut self, delivery_witness: B256) -> Self { + self.delivery_witness = Some(delivery_witness); + self + } + + /// Attach opaque provider-specific resume state represented by this state. + pub fn with_subscriber_checkpoint(mut self, checkpoint: impl Into>) -> Self { + self.subscriber_checkpoint = Some(checkpoint.into()); + self + } + + /// Attach opaque core-runtime recovery state represented by this snapshot. + pub fn with_runtime_checkpoint(mut self, checkpoint: impl Into>) -> Self { + self.runtime_checkpoint = Some(checkpoint.into()); + self + } +} + +/// Filesystem-backed durable checkpoint store. +/// +/// Every store constructed for the same normalized path in one process shares +/// writer generations, so a cancelled older async save cannot replace a newer +/// request even when the callers did not clone the same store value. Deployments +/// must still enforce one writer process per checkpoint path; filesystem rename +/// atomicity does not establish ordering between independent processes. Atomic +/// saves currently require Unix; unsupported platforms return a typed error +/// rather than falling back to a remove-then-rename durability gap. +#[derive(Clone, Debug)] +pub struct DurableCheckpointStore { + path: PathBuf, + coordinator: Arc, + max_checkpoint_bytes: u64, +} + +#[derive(Debug, Default)] +struct CheckpointWriteCoordinator { + latest_generation: AtomicU64, + writer: Mutex<()>, +} + +impl PartialEq for DurableCheckpointStore { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} + +impl Eq for DurableCheckpointStore {} + +impl DurableCheckpointStore { + /// Use `path` as the single atomic checkpoint file. + pub fn new(path: impl Into) -> Self { + let path = normalized_checkpoint_path(&path.into()); + Self { + coordinator: checkpoint_coordinator(&path), + path, + max_checkpoint_bytes: DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES, + } + } + + /// Override the maximum encoded checkpoint size accepted for reads and + /// writes. The default is [`DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES`]. + /// + /// This bounds file reads and the encoded write allocation; it is not a + /// retention target. Snapshot capture still owns a clone of the cache state + /// before measuring its serialized size, so services must budget capture + /// memory separately. Lower the bound for tightly constrained services or + /// raise it deliberately for unusually large caches. + pub fn with_max_checkpoint_bytes(mut self, max_checkpoint_bytes: u64) -> Self { + self.max_checkpoint_bytes = max_checkpoint_bytes; + self + } + + /// Maximum encoded checkpoint size accepted by this store. + pub fn max_checkpoint_bytes(&self) -> u64 { + self.max_checkpoint_bytes + } + + /// Path of the checkpoint file. + pub fn path(&self) -> &Path { + &self.path + } + + /// Persist a complete cache snapshot and its commit metadata atomically. + /// + /// The new file is written and synced in the same directory, renamed over + /// the previous checkpoint, and then the parent directory is synced. A + /// failure before the rename leaves the previous committed file intact. + /// The final rename replaces the destination directory entry itself; if the + /// destination is a symlink, the symlink is replaced rather than followed. + /// + /// This low-level API can verify the cache's chain id, but cannot prove that + /// its event-maintained state includes every effect through + /// `metadata.block`. It trusts that caller assertion and normalizes the + /// persisted exact pin/context to it. Production reactive consumers should + /// normally use `ReactiveEngine::*_checkpointed`, which derives metadata + /// from the batch committed with the runtime state. + /// + /// # Errors + /// + /// Returns [`DurableCheckpointError`] when cache and metadata chain/context + /// identity disagree, the generation counter is exhausted, encoding exceeds + /// the configured size bound, or atomic write/sync/replace fails. + pub fn save( + &self, + cache: &EvmCache, + metadata: DurableCheckpointMetadata, + ) -> Result<(), DurableCheckpointError> { + validate_capture_identity(cache, &metadata)?; + // Request order is assigned before the potentially expensive snapshot + // capture. Otherwise an older large capture can finish after a newer + // small capture, reserve the later generation, and overwrite it. + let generation = self.reserve_generation()?; + let snapshot = DurableCheckpointSnapshot::capture(cache, metadata); + persist_snapshot( + &self.path, + snapshot, + &self.coordinator, + generation, + self.max_checkpoint_bytes, + ) + } + + /// Persist a complete cache snapshot without serializing or syncing the + /// checkpoint file on the async runtime worker. + /// + /// Capturing the owned cache snapshot is synchronous, but the potentially + /// long bincode encode, file write, fsync, rename, and directory fsync run + /// on Tokio's blocking pool. This keeps checkpoint durability out of event + /// transport and heartbeat scheduling paths. + /// The same low-level metadata trust contract as [`save`](Self::save) + /// applies. + /// + /// # Errors + /// + /// Returns [`DurableCheckpointError`] for the same identity, generation, + /// size, encoding, and filesystem failures as [`save`](Self::save), or when + /// Tokio's blocking task cannot be joined. + pub async fn save_async( + &self, + cache: &EvmCache, + metadata: DurableCheckpointMetadata, + ) -> Result<(), DurableCheckpointError> { + validate_capture_identity(cache, &metadata)?; + // See `save`: generation order is request-entry order, not + // capture-completion order. Capture is infallible after validation. + let generation = self.reserve_generation()?; + let snapshot = DurableCheckpointSnapshot::capture(cache, metadata); + let path = self.path.clone(); + let coordinator = Arc::clone(&self.coordinator); + let max_checkpoint_bytes = self.max_checkpoint_bytes; + tokio::task::spawn_blocking(move || { + persist_snapshot( + &path, + snapshot, + &coordinator, + generation, + max_checkpoint_bytes, + ) + }) + .await + .map_err(DurableCheckpointError::TaskJoin)? + } + + fn reserve_generation(&self) -> Result { + self.coordinator + .latest_generation + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |generation| { + generation.checked_add(1) + }) + .map(|previous| previous + 1) + .map_err(|_| DurableCheckpointError::GenerationExhausted) + } + + /// Load a checkpoint without mutating a cache. + /// + /// This split lets callers inspect and RPC-validate the canonical hash in + /// [`LoadedDurableCheckpoint::metadata`] before choosing to restore it. + /// The configured size ceiling and integrity checksum are verified before + /// any checkpoint payload is decoded. + /// + /// # Errors + /// + /// Returns [`DurableCheckpointError`] for read/metadata failures, oversized + /// files, invalid magic/version/encoding, checksum mismatch, or malformed + /// checkpoint content. A missing file returns `Ok(None)`. + pub fn load(&self) -> Result, DurableCheckpointError> { + let file = match File::open(&self.path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(DurableCheckpointError::Read { + path: self.path.clone(), + source, + }); + } + }; + let reported_bytes = file + .metadata() + .map_err(|source| DurableCheckpointError::Read { + path: self.path.clone(), + source, + })? + .len(); + if reported_bytes > self.max_checkpoint_bytes { + return Err(DurableCheckpointError::CheckpointTooLarge { + path: self.path.clone(), + bytes: reported_bytes, + max_bytes: self.max_checkpoint_bytes, + }); + } + let mut data = Vec::new(); + file.take(self.max_checkpoint_bytes.saturating_add(1)) + .read_to_end(&mut data) + .map_err(|source| DurableCheckpointError::Read { + path: self.path.clone(), + source, + })?; + if data.len() as u64 > self.max_checkpoint_bytes { + return Err(DurableCheckpointError::CheckpointTooLarge { + path: self.path.clone(), + bytes: data.len() as u64, + max_bytes: self.max_checkpoint_bytes, + }); + } + let Some(checksum_start) = data.len().checked_sub(CHECKPOINT_CHECKSUM_BYTES) else { + return Err(DurableCheckpointError::InvalidFormat { + path: self.path.clone(), + }); + }; + let encoded = &data[..checksum_start]; + let expected = B256::from_slice(&data[checksum_start..]); + let actual = keccak256(encoded); + if actual != expected { + return Err(DurableCheckpointError::ChecksumMismatch { + path: self.path.clone(), + }); + } + let snapshot = versioned::decode( + encoded, + CHECKPOINT_MAGIC, + CHECKPOINT_VERSION, + CHECKPOINT_LABEL, + ) + .ok_or_else(|| DurableCheckpointError::InvalidFormat { + path: self.path.clone(), + })?; + Ok(Some(LoadedDurableCheckpoint { snapshot })) + } +} + +fn checkpoint_coordinator(path: &Path) -> Arc { + let key = normalized_checkpoint_path(path); + let coordinators = CHECKPOINT_COORDINATORS.get_or_init(|| Mutex::new(HashMap::new())); + let mut coordinators = coordinators + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(coordinator) = coordinators.get(&key).and_then(Weak::upgrade) { + return coordinator; + } + coordinators.retain(|_, coordinator| coordinator.strong_count() > 0); + let coordinator = Arc::new(CheckpointWriteCoordinator::default()); + coordinators.insert(key, Arc::downgrade(&coordinator)); + coordinator +} + +fn normalized_checkpoint_path(path: &Path) -> PathBuf { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|directory| directory.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + }; + + // Canonicalize the directory identity while deliberately preserving the + // destination entry itself. This makes aliases through symlinked parents + // share a writer coordinator, but an existing destination symlink is + // replaced by the atomic rename rather than followed to another file. + let Some(file_name) = absolute.file_name() else { + return normalize_existing_path_prefix(&absolute); + }; + let parent = absolute.parent().unwrap_or_else(|| Path::new(".")); + normalize_existing_path_prefix(parent).join(file_name) +} + +fn normalize_existing_path_prefix(absolute: &Path) -> PathBuf { + // Resolve every existing prefix through the OS before normalizing a + // missing suffix. This preserves `symlink/..` semantics (which differ from + // blindly popping path components) while producing the same key before and + // after this store creates an ordinary missing directory suffix. + let components: Vec<_> = absolute.components().collect(); + for split in (1..=components.len()).rev() { + let prefix: PathBuf = components[..split] + .iter() + .map(|component| component.as_os_str()) + .collect(); + let Ok(mut resolved) = prefix.canonicalize() else { + continue; + }; + for component in &components[split..] { + match component { + Component::Prefix(prefix) => resolved.push(prefix.as_os_str()), + Component::RootDir => resolved.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + let _ = resolved.pop(); + } + Component::Normal(part) => resolved.push(part), + } + } + return resolved; + } + + // Absolute roots normally make the loop succeed. Retain a deterministic + // fallback for unusual platforms/current-directory failures. + absolute.to_path_buf() +} + +fn validate_capture_identity( + cache: &EvmCache, + metadata: &DurableCheckpointMetadata, +) -> Result<(), DurableCheckpointError> { + if metadata.identity.chain_id != cache.chain_id { + return Err(DurableCheckpointError::CacheChainMismatch { + cache_chain_id: cache.chain_id, + checkpoint_chain_id: metadata.identity.chain_id, + }); + } + Ok(()) +} + +fn persist_snapshot( + path: &Path, + snapshot: DurableCheckpointSnapshot, + coordinator: &CheckpointWriteCoordinator, + generation: u64, + max_checkpoint_bytes: u64, +) -> Result<(), DurableCheckpointError> { + let _writer = coordinator + .writer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let latest = coordinator.latest_generation.load(Ordering::Acquire); + if generation != latest { + return Err(DurableCheckpointError::WriteSuperseded { generation, latest }); + } + // Measure through bincode's size counter before allocating the encoded + // payload. The configured ceiling is a memory-safety boundary as well as a + // file-size boundary; checking only after `encode` would allocate the full + // checkpoint first. + let payload_bytes = bincode::serialized_size(&snapshot).map_err(|source| { + DurableCheckpointError::Encode(crate::errors::PersistenceError::serialize( + CHECKPOINT_LABEL, + source, + )) + })?; + let encoded_bytes = payload_bytes + .checked_add(CHECKPOINT_HEADER_BYTES) + .and_then(|bytes| bytes.checked_add(CHECKPOINT_CHECKSUM_BYTES as u64)) + .ok_or(DurableCheckpointError::CheckpointSizeOverflow { + path: path.to_path_buf(), + })?; + if encoded_bytes > max_checkpoint_bytes { + return Err(DurableCheckpointError::CheckpointTooLarge { + path: path.to_path_buf(), + bytes: encoded_bytes, + max_bytes: max_checkpoint_bytes, + }); + } + let mut data = versioned::encode( + CHECKPOINT_MAGIC, + CHECKPOINT_VERSION, + &snapshot, + CHECKPOINT_LABEL, + ) + .map_err(DurableCheckpointError::Encode)?; + let checksum = keccak256(&data); + data.extend_from_slice(checksum.as_slice()); + debug_assert_eq!(data.len() as u64, encoded_bytes); + atomic_replace(path, &data) +} + +/// A decoded checkpoint awaiting identity/hash validation and restore. +pub struct LoadedDurableCheckpoint { + snapshot: DurableCheckpointSnapshot, +} + +impl LoadedDurableCheckpoint { + /// Inspect commit metadata before mutating the cache. + pub fn metadata(&self) -> &DurableCheckpointMetadata { + &self.snapshot.metadata + } + + /// Restore the checkpoint into an already configured cache. + /// + /// The identity must match exactly and the cache must target the same chain. + /// Callers should validate `metadata().block.hash` against an authoritative + /// RPC source first when the checkpoint block is not finalized. + /// + /// # Errors + /// + /// Returns [`DurableCheckpointError::IdentityMismatch`] when `expected` + /// differs from stored metadata, or + /// [`DurableCheckpointError::CacheChainMismatch`] when the configured cache + /// targets another chain. The cache is not mutated on either error. + pub fn restore_into( + self, + cache: &mut EvmCache, + expected: &DurableCheckpointIdentity, + ) -> Result { + if &self.snapshot.metadata.identity != expected { + return Err(DurableCheckpointError::IdentityMismatch { + expected: expected.clone(), + actual: self.snapshot.metadata.identity.clone(), + }); + } + if cache.chain_id != expected.chain_id { + return Err(DurableCheckpointError::CacheChainMismatch { + cache_chain_id: cache.chain_id, + checkpoint_chain_id: expected.chain_id, + }); + } + Ok(self.snapshot.restore(cache)) + } +} + +#[derive(Serialize, Deserialize)] +struct DurableCheckpointSnapshot { + metadata: DurableCheckpointMetadata, + state: EvmCacheStateSnapshot, +} + +/// Complete mutable cache state used both by the on-disk checkpoint and by the +/// checkpointed engine's in-process rollback guard. +#[derive(Clone, Serialize, Deserialize)] +pub(crate) struct EvmCacheStateSnapshot { + backend_accounts: Vec<(Address, AccountInfo)>, + backend_storage: Vec<(Address, Vec<(U256, U256)>)>, + backend_block_hashes: Vec<(U256, B256)>, + overlay: Cache, + token_decimals: HashMap, + immutable_cache: ImmutableDataCache, + code_seeds: HashMap, + erc20_balance_slots: HashMap, + block: PersistedBlockId, + block_number: Option, + basefee: Option, + coinbase: Option
, + prevrandao: Option, + block_gas_limit: Option, + timestamp_override: Option, + block_env_source: Option, + spec_id: SpecId, + snapshot_generation: u64, +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +enum PersistedBlockId { + Hash { + hash: B256, + require_canonical: Option, + }, + Latest, + Finalized, + Safe, + Earliest, + Pending, + Number(u64), +} + +impl From for PersistedBlockId { + fn from(block: BlockId) -> Self { + match block { + BlockId::Hash(hash) => Self::Hash { + hash: hash.block_hash, + require_canonical: hash.require_canonical, + }, + BlockId::Number(BlockNumberOrTag::Latest) => Self::Latest, + BlockId::Number(BlockNumberOrTag::Finalized) => Self::Finalized, + BlockId::Number(BlockNumberOrTag::Safe) => Self::Safe, + BlockId::Number(BlockNumberOrTag::Earliest) => Self::Earliest, + BlockId::Number(BlockNumberOrTag::Pending) => Self::Pending, + BlockId::Number(BlockNumberOrTag::Number(number)) => Self::Number(number), + } + } +} + +impl From for BlockId { + fn from(block: PersistedBlockId) -> Self { + match block { + PersistedBlockId::Hash { + hash, + require_canonical, + } => BlockId::Hash(RpcBlockHash::from_hash(hash, require_canonical)), + PersistedBlockId::Latest => BlockId::latest(), + PersistedBlockId::Finalized => BlockId::finalized(), + PersistedBlockId::Safe => BlockId::safe(), + PersistedBlockId::Earliest => BlockId::earliest(), + PersistedBlockId::Pending => BlockId::pending(), + PersistedBlockId::Number(number) => BlockId::number(number), + } + } +} + +impl DurableCheckpointSnapshot { + fn capture(cache: &EvmCache, metadata: DurableCheckpointMetadata) -> Self { + let mut state = EvmCacheStateSnapshot::capture(cache); + state.align_to_checkpoint_block(&metadata.block); + Self { metadata, state } + } + + fn restore(self, cache: &mut EvmCache) -> DurableCheckpointMetadata { + let block_hash = self.metadata.block.hash; + self.state.restore(cache); + + // Keep lazy RPC misses pinned to exactly the validated canonical block. + // A number-only pin could silently mix this snapshot with a replacement + // branch after a shallow reorg. Setting + // the backend pin directly avoids `set_block` clearing the restored EVM + // block context and incrementing the restored generation. + let block = alloy_eips::BlockId::from((block_hash, Some(true))); + cache.block = block; + let _ = cache.backend.set_pinned_block(block); + + self.metadata + } +} + +impl EvmCacheStateSnapshot { + pub(crate) fn capture(cache: &EvmCache) -> Self { + let (backend_accounts, backend_storage, backend_block_hashes) = + capture_backend_maps(&cache.blockchain_db); + + Self { + backend_accounts, + backend_storage, + backend_block_hashes, + overlay: cache.db.cache.clone(), + token_decimals: cache.token_decimals.clone(), + immutable_cache: cache.immutable_cache.clone(), + code_seeds: cache.code_seeds.clone(), + erc20_balance_slots: cache.erc20_balance_slots.clone(), + block: cache.block.into(), + block_number: cache.block_number, + basefee: cache.basefee, + coinbase: cache.coinbase, + prevrandao: cache.prevrandao, + block_gas_limit: cache.block_gas_limit, + timestamp_override: cache.timestamp_override, + block_env_source: cache.block_env_source, + spec_id: cache.spec_id, + snapshot_generation: cache.snapshot_generation, + } + } + + /// Make the persisted execution context internally agree with the + /// checkpoint's authoritative canonical coverage. + /// + /// Compact indexer progress can advance the checkpoint without delivering + /// a full header. In that case carrying an older cache `NUMBER`, timestamp, + /// or fee environment alongside the newer exact pin would create a split + /// state on restore. A full environment is retained only when cache + /// provenance proves it came from this exact number/hash and any supplied + /// checkpoint timestamp agrees. Otherwise number and timestamp are aligned + /// from metadata and unproven header-only fields are cleared. + fn align_to_checkpoint_block(&mut self, block: &DurableCheckpointBlock) { + let preserve_full_env = matches!( + self.block_env_source, + Some(BlockEnvSource::VerifiedHash { number, hash }) + if number == block.number + && hash == block.hash + && block + .timestamp + .zip(self.timestamp_override) + .is_none_or(|(expected, actual)| expected == actual) + ); + self.block = PersistedBlockId::Hash { + hash: block.hash, + require_canonical: Some(true), + }; + self.block_number = Some(block.number); + if !preserve_full_env { + self.timestamp_override = block.timestamp; + self.basefee = None; + self.coinbase = None; + self.prevrandao = None; + self.block_gas_limit = None; + self.block_env_source = None; + } + } + + pub(crate) fn restore(self, cache: &mut EvmCache) { + { + let mut accounts = cache.blockchain_db.accounts().write(); + accounts.clear(); + accounts.extend(self.backend_accounts); + } + { + let mut storage = cache.blockchain_db.storage().write(); + storage.clear(); + storage.extend( + self.backend_storage + .into_iter() + .map(|(address, slots)| (address, slots.into_iter().collect())), + ); + } + { + let mut hashes = cache.blockchain_db.block_hashes().write(); + hashes.clear(); + hashes.extend(self.backend_block_hashes); + } + + cache.db.cache = self.overlay; + cache.token_decimals = self.token_decimals; + cache.immutable_cache = self.immutable_cache; + cache.code_seeds = self.code_seeds; + cache.erc20_balance_slots = self.erc20_balance_slots; + let block = BlockId::from(self.block); + cache.block = block; + let _ = cache.backend.set_pinned_block(block); + cache.block_number = self.block_number; + cache.basefee = self.basefee; + cache.coinbase = self.coinbase; + cache.prevrandao = self.prevrandao; + cache.block_gas_limit = self.block_gas_limit; + cache.timestamp_override = self.timestamp_override; + cache.block_env_source = self.block_env_source; + cache.spec_id = self.spec_id; + cache.snapshot_generation = self.snapshot_generation; + cache.base = None; + cache.base_dirty.clear(); + cache.base_full_rebuild = true; + cache.base_storage_lens.clear(); + } +} + +type BackendMapsSnapshot = ( + Vec<(Address, AccountInfo)>, + Vec<(Address, Vec<(U256, U256)>)>, + Vec<(U256, B256)>, +); + +fn capture_backend_maps(blockchain_db: &BlockchainDb) -> BackendMapsSnapshot { + // Hold all three backend read guards together. `SharedBackend` applies + // queued account/storage/block-hash mutations through these same locks; + // retaining the earlier guards while later maps are cloned therefore gives + // the snapshot one coherent point-in-time prefix instead of an impossible + // old-account/new-storage mixture. The backend handler never holds more + // than one of these locks, so this stable order cannot form a cycle with + // normal lazy RPC population. + let accounts = blockchain_db.accounts().read(); + let storage = blockchain_db.storage().read(); + let block_hashes = blockchain_db.block_hashes().read(); + let backend_accounts = accounts + .iter() + .map(|(address, info)| (*address, info.clone())) + .collect(); + let backend_storage = storage + .iter() + .map(|(address, slots)| { + ( + *address, + slots.iter().map(|(key, value)| (*key, *value)).collect(), + ) + }) + .collect(); + let backend_block_hashes = block_hashes + .iter() + .map(|(number, hash)| (*number, *hash)) + .collect(); + (backend_accounts, backend_storage, backend_block_hashes) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + sync::{Arc, Barrier}, + thread, + time::{Duration, Instant}, + }; + + use foundry_fork_db::{BlockchainDb, cache::BlockchainDbMeta}; + + use super::capture_backend_maps; + #[cfg(unix)] + use super::create_unique_temp_file; + + #[test] + fn backend_capture_retains_earlier_guards_while_waiting_for_later_maps() { + let blockchain_db = Arc::new(BlockchainDb::new(BlockchainDbMeta::default(), None)); + let storage_guard = blockchain_db.storage().write(); + let start = Arc::new(Barrier::new(2)); + let worker_db = Arc::clone(&blockchain_db); + let worker_start = Arc::clone(&start); + let capture = thread::spawn(move || { + worker_start.wait(); + capture_backend_maps(&worker_db) + }); + start.wait(); + + // The worker can acquire the accounts read guard, but must block on the + // storage write guard held above. A per-map clone that dropped the first + // guard would allow this write; coherent capture must keep rejecting it. + let deadline = Instant::now() + Duration::from_secs(2); + let retained_accounts_guard = loop { + if blockchain_db.accounts().try_write().is_none() { + break true; + } + if Instant::now() >= deadline { + break false; + } + thread::yield_now(); + }; + assert!( + retained_accounts_guard, + "capture must retain the accounts guard while awaiting storage" + ); + + drop(storage_guard); + capture.join().expect("capture thread"); + } + + #[cfg(unix)] + #[test] + fn stale_temp_candidate_is_skipped_without_blocking_checkpoint_progress() { + use std::os::unix::fs::PermissionsExt; + + let root = std::env::temp_dir().join(format!( + "evm-fork-cache-stale-temp-{}-{}", + std::process::id(), + super::NEXT_TEMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir_all(&root).expect("create test directory"); + let destination = root.join("checkpoint.bin"); + let stale = root.join(".checkpoint.bin.first"); + let fresh = root.join(".checkpoint.bin.second"); + fs::write(&stale, b"stale crash residue").expect("precreate first candidate"); + let mut candidates = [stale.clone(), fresh.clone()].into_iter(); + + let (selected, file) = create_unique_temp_file(&destination, || { + candidates.next().expect("bounded test candidates") + }) + .expect("collision must retry with the next candidate"); + drop(file); + + assert_eq!(selected, fresh); + assert_eq!( + fs::metadata(&selected) + .expect("fresh temp metadata") + .permissions() + .mode() + & 0o777, + 0o600, + "checkpoint temp files contain provider cursors and must be owner-only" + ); + assert_eq!( + fs::read(&stale).expect("stale file remains"), + b"stale crash residue" + ); + fs::remove_dir_all(root).expect("remove test directory"); + } +} + +#[cfg(unix)] +fn atomic_replace(path: &Path, data: &[u8]) -> Result<(), DurableCheckpointError> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).map_err(|source| DurableCheckpointError::CreateDir { + path: parent.to_path_buf(), + source, + })?; + + let (temp_path, mut file) = create_unique_temp_file(path, || next_temp_path(path))?; + let result = (|| { + file.write_all(data) + .and_then(|()| file.sync_all()) + .map_err(|source| DurableCheckpointError::Write { + path: temp_path.clone(), + source, + })?; + fs::rename(&temp_path, path).map_err(|source| DurableCheckpointError::Rename { + from: temp_path.clone(), + to: path.to_path_buf(), + source, + })?; + sync_parent_directory(parent)?; + Ok(()) + })(); + + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +#[cfg(not(unix))] +fn atomic_replace(path: &Path, _data: &[u8]) -> Result<(), DurableCheckpointError> { + Err(DurableCheckpointError::AtomicReplaceUnsupported { + path: path.to_path_buf(), + }) +} + +#[cfg(unix)] +fn create_unique_temp_file( + destination: &Path, + mut next_candidate: impl FnMut() -> PathBuf, +) -> Result<(PathBuf, File), DurableCheckpointError> { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + for _ in 0..MAX_TEMP_CREATE_ATTEMPTS { + let candidate = next_candidate(); + match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&candidate) + { + Ok(file) => { + // `mode` prevents a permissive creation window; explicitly + // resetting permissions also makes the contract independent + // of an unusually restrictive process umask. + if let Err(source) = file.set_permissions(fs::Permissions::from_mode(0o600)) { + drop(file); + let _ = fs::remove_file(&candidate); + return Err(DurableCheckpointError::Write { + path: candidate, + source, + }); + } + return Ok((candidate, file)); + } + Err(source) if source.kind() == io::ErrorKind::AlreadyExists => continue, + Err(source) => { + return Err(DurableCheckpointError::Write { + path: candidate, + source, + }); + } + } + } + Err(DurableCheckpointError::TemporaryPathExhausted { + path: destination.to_path_buf(), + attempts: MAX_TEMP_CREATE_ATTEMPTS, + }) +} + +#[cfg(unix)] +fn sync_parent_directory(parent: &Path) -> Result<(), DurableCheckpointError> { + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|source| DurableCheckpointError::SyncDirectory { + path: parent.to_path_buf(), + source, + }) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_parent: &Path) -> Result<(), DurableCheckpointError> { + // Rust has no portable directory-fsync primitive. The file itself has + // already been synced before the atomic replace on these targets. + Ok(()) +} + +#[cfg(unix)] +fn next_temp_path(path: &Path) -> PathBuf { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("checkpoint"); + path.with_file_name(format!(".{name}.tmp-{}-{id}", std::process::id())) +} + +/// Durable checkpoint persistence or compatibility failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum DurableCheckpointError { + /// This platform cannot provide the crate's required atomic replacement. + #[error("atomic durable checkpoint replacement for {path:?} is unsupported on this platform")] + AtomicReplaceUnsupported { + /// Destination checkpoint path. + path: PathBuf, + }, + /// The checkpoint payload could not be serialized. + #[error(transparent)] + Encode(#[from] crate::errors::PersistenceError), + /// The blocking checkpoint writer task was cancelled or panicked. + #[error("durable checkpoint writer task failed: {0}")] + TaskJoin(#[source] tokio::task::JoinError), + /// The in-process writer generation counter cannot advance safely. + #[error("durable checkpoint writer generation exhausted")] + GenerationExhausted, + /// A newer write was requested before this writer could install its snapshot. + #[error( + "durable checkpoint write generation {generation} was superseded by generation {latest}" + )] + WriteSuperseded { + /// Generation assigned to this write. + generation: u64, + /// Latest generation requested from this store. + latest: u64, + }, + /// The checkpoint file could not be read. + #[error("failed to read durable checkpoint {path:?}: {source}")] + Read { + /// Checkpoint path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: io::Error, + }, + /// The file does not carry the supported magic, version, or payload. + #[error("durable checkpoint {path:?} has an invalid or unsupported format")] + InvalidFormat { + /// Checkpoint path. + path: PathBuf, + }, + /// The checkpoint checksum does not match its encoded contents. + #[error("durable checkpoint {path:?} failed its integrity checksum")] + ChecksumMismatch { + /// Checkpoint path. + path: PathBuf, + }, + /// The checkpoint exceeds this store's configured resource bound. + #[error( + "durable checkpoint {path:?} is {bytes} bytes, exceeding the configured {max_bytes}-byte limit" + )] + CheckpointTooLarge { + /// Checkpoint path. + path: PathBuf, + /// Encoded file size observed or produced. + bytes: u64, + /// Maximum encoded size accepted by the store. + max_bytes: u64, + }, + /// Encoded checkpoint size overflowed the supported `u64` accounting. + #[error("durable checkpoint {path:?} size exceeds supported accounting")] + CheckpointSizeOverflow { + /// Checkpoint path. + path: PathBuf, + }, + /// The checkpoint directory could not be created. + #[error("failed to create durable checkpoint directory {path:?}: {source}")] + CreateDir { + /// Directory path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: io::Error, + }, + /// The temporary checkpoint file could not be written or synced. + #[error("failed to write durable checkpoint {path:?}: {source}")] + Write { + /// Temporary checkpoint path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: io::Error, + }, + /// Every bounded unique temporary-file candidate already existed. + #[error( + "failed to allocate a unique temporary file for durable checkpoint {path:?} after {attempts} attempts" + )] + TemporaryPathExhausted { + /// Destination checkpoint path. + path: PathBuf, + /// Number of collision retries attempted. + attempts: usize, + }, + /// The synced temporary file could not be atomically installed. + #[error("failed to replace durable checkpoint {to:?} from {from:?}: {source}")] + Rename { + /// Temporary checkpoint path. + from: PathBuf, + /// Destination checkpoint path. + to: PathBuf, + /// Filesystem failure. + #[source] + source: io::Error, + }, + /// The checkpoint rename committed but its containing directory could not + /// be synced. The file may exist, but the caller must not acknowledge the + /// delivery because crash durability was not established. + #[error("failed to sync durable checkpoint directory {path:?}: {source}")] + SyncDirectory { + /// Directory path. + path: PathBuf, + /// Filesystem failure. + #[source] + source: io::Error, + }, + /// A checkpoint was opened under a different subscriber or handler schema. + #[error("durable checkpoint identity mismatch: expected {expected:?}, found {actual:?}")] + IdentityMismatch { + /// Requested consumer identity. + expected: DurableCheckpointIdentity, + /// Stored consumer identity. + actual: DurableCheckpointIdentity, + }, + /// The configured cache and checkpoint identity target different chains. + #[error( + "durable checkpoint chain {checkpoint_chain_id} does not match cache chain {cache_chain_id}" + )] + CacheChainMismatch { + /// Current cache chain id. + cache_chain_id: u64, + /// Stored/requested checkpoint chain id. + checkpoint_chain_id: u64, + }, +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 17607cd..451b2aa 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -16,6 +16,7 @@ mod binary_state; mod bytecode; mod code_seeds; +mod durable_checkpoint; mod journal_access_list; mod metadata; pub mod overlay; @@ -24,6 +25,13 @@ pub mod snapshot; pub(crate) mod versioned; pub use binary_state::{load_binary_state, save_binary_state}; +#[cfg(feature = "reactive")] +pub(crate) use durable_checkpoint::EvmCacheStateSnapshot; +pub use durable_checkpoint::{ + DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES, DurableCheckpointBlock, DurableCheckpointError, + DurableCheckpointIdentity, DurableCheckpointMetadata, DurableCheckpointStore, + LoadedDurableCheckpoint, +}; pub use metadata::{CacheConfig, ImmutableDataCache}; pub use overlay::EvmOverlay; pub use slot_observations::SlotObservationTracker; @@ -1475,8 +1483,8 @@ pub struct EvmCache { cache_config: Option, /// Cache for immutable on-chain data (token decimals). immutable_cache: ImmutableDataCache, - /// Optional timestamp override for simulating future blocks. - /// When set, EVM simulations use this timestamp instead of the current system time. + /// Timestamp installed from a full/compact block identity or overridden for + /// future-block simulation. `None` falls back to the current system time. timestamp_override: Option, /// Chain ID for EVM simulation (e.g. 42161 for Arbitrum, 1 for Ethereum). chain_id: u64, @@ -1500,6 +1508,10 @@ pub struct EvmCache { /// builder path sets it before returning. Enforced by /// [`advance_block`](Self::advance_block). block_context_requirements: BlockContextRequirements, + /// Provenance for the currently installed full block environment. A header + /// number becomes exact only when the reactive runtime pairs it with the + /// independently validated canonical hash carried by the input context. + block_env_source: Option, /// Cache-side batch-fetch configuration for this instance. storage_batch_config: StorageBatchConfig, /// Shared memory buffer reused across EVM simulations. @@ -1572,6 +1584,12 @@ pub struct EvmCache { shared_memory_capacity: usize, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +enum BlockEnvSource { + HeaderNumber(u64), + VerifiedHash { number: u64, hash: B256 }, +} + /// Outcome of a balance-delta-tracking simulation. /// /// Produced by [`EvmCache::simulate_call_with_balance_deltas`] and @@ -1821,6 +1839,13 @@ impl EvmCache { (None, None, None, None, None, None) } }; + let block_env_source = block_number.map(|number| match block_id { + BlockId::Hash(hash) => BlockEnvSource::VerifiedHash { + number, + hash: hash.block_hash, + }, + BlockId::Number(_) => BlockEnvSource::HeaderNumber(number), + }); // Ensure cache directory exists if let Some(cfg) = &cache_config { @@ -2094,6 +2119,7 @@ impl EvmCache { prevrandao, block_gas_limit, block_context_requirements: BlockContextRequirements::lenient(), + block_env_source, storage_batch_config, shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity(shared_memory_capacity))), snapshot_generation: 0, @@ -2182,6 +2208,7 @@ impl EvmCache { prevrandao: None, block_gas_limit: None, block_context_requirements: BlockContextRequirements::lenient(), + block_env_source: None, storage_batch_config: StorageBatchConfig::default(), snapshot_generation: 0, shared_memory_buffer: Rc::new(RefCell::new(Vec::with_capacity( @@ -2280,6 +2307,7 @@ impl EvmCache { pub fn with_blockchain_db_mut(&mut self, f: impl FnOnce(&BlockchainDb) -> R) -> R { let result = f(&self.blockchain_db); self.invalidate_base(); + self.bump_snapshot_generation(); result } @@ -2345,6 +2373,9 @@ impl EvmCache { /// into the BlockchainDb backend, so parallel tasks sharing the backend /// will not see them. Prefer the higher-level mutators; use with care. pub fn db_mut(&mut self) -> &mut ForkCacheDB { + // Mutable access may change checkpointed overlay state. Bump on access + // because changes through the returned reference cannot be observed. + self.bump_snapshot_generation(); &mut self.db } @@ -3469,7 +3500,10 @@ impl EvmCache { /// [`snapshot`](Self::snapshot) / `build_evm`; existing /// snapshots and overlays keep the chain ID captured when they were created. pub fn set_chain_id(&mut self, chain_id: u64) { - self.chain_id = chain_id; + if self.chain_id != chain_id { + self.chain_id = chain_id; + self.bump_snapshot_generation(); + } } /// Take a low-level, same-thread checkpoint of the CacheDB overlay for @@ -3931,15 +3965,24 @@ impl EvmCache { /// ids (`latest`, `pending`, hashes, etc.), the height is not /// statically known, so `block_number` is cleared. /// - /// `basefee` (the `BASEFEE` opcode) is **cleared on every block change** and - /// on every non-concrete tag/hash pin call because deriving it requires - /// fetching the block header, which this synchronous method cannot do. Callers - /// that change blocks should refresh it via - /// [`set_block_context`](Self::set_block_context) after fetching the new - /// header. Prefer [`repin_to_block`](Self::repin_to_block) when re-pinning to - /// a concrete height, since it keeps `block_number` and the pinned block in - /// lockstep. + /// Every header-derived execution-context field (`basefee`, beneficiary, + /// `prevrandao`, gas limit, and timestamp) is **cleared on every block + /// change** and on every non-concrete tag/hash pin call. Deriving those + /// values requires fetching the block header, which this synchronous method + /// cannot do. This also clears values installed through the manual context + /// setters; callers that intentionally override them must reapply the + /// overrides after the repin. Prefer [`advance_block`](Self::advance_block) + /// when a complete header is available, or refresh the individual fields + /// after [`repin_to_block`](Self::repin_to_block). pub fn set_block(&mut self, block: BlockId) { + let previous_block_number = self.block_number; + let previous_context = ( + self.basefee, + self.coinbase, + self.prevrandao, + self.block_gas_limit, + self.timestamp_override, + ); let changed = self.block != block; let concrete_number = match block { BlockId::Number(BlockNumberOrTag::Number(n)) => Some(n), @@ -3955,12 +3998,31 @@ impl EvmCache { } if changed || concrete_number.is_none() { self.basefee = None; + self.coinbase = None; + self.prevrandao = None; + self.block_gas_limit = None; + self.timestamp_override = None; } // Keep the EVM `NUMBER` opcode aligned with the pin. Only a concrete // height is meaningful; tags and hashes clear it so a stale number from // an earlier concrete block cannot leak into simulation. self.block_number = concrete_number; + let context_changed = self.block_number != previous_block_number + || previous_context + != ( + self.basefee, + self.coinbase, + self.prevrandao, + self.block_gas_limit, + self.timestamp_override, + ); + if !changed && context_changed { + self.bump_snapshot_generation(); + } + if changed || context_changed { + self.block_env_source = None; + } } /// Get the block that RPC fetches are currently pinned to. @@ -3973,8 +4035,13 @@ impl EvmCache { /// Increments on every targeted state write ([`apply_update`](Self::apply_update), /// [`apply_updates`](Self::apply_updates), [`modify_slot`](Self::modify_slot) /// — and therefore everything built on them: reactive ingestion, freshness - /// corrections, fresh injections) and on block re-pins - /// ([`set_block`](Self::set_block), [`advance_block`](Self::advance_block)). + /// corrections, fresh injections), block re-pins, and persisted execution + /// context changes. Mutable access through [`db_mut`](Self::db_mut) and + /// [`with_blockchain_db_mut`](Self::with_blockchain_db_mut) advances it + /// conservatively. Interior mutation through + /// [`unchecked_blockchain_db`](Self::unchecked_blockchain_db) or + /// [`unchecked_backend`](Self::unchecked_backend) remains explicitly + /// outside this contract. /// Cold prefetch ([`inject_storage_batch`](Self::inject_storage_batch)) and /// lazy backend fetches do **not** increment it: they materialize the pinned /// block's existing state rather than changing it. @@ -4018,8 +4085,14 @@ impl EvmCache { /// time-dependent opportunities (like yield farming rewards) become profitable. /// /// Pass `None` to use the current system time (default behavior). + /// Re-pinning through [`set_block`](Self::set_block) clears the override; + /// apply it after the repin when a custom timestamp should remain in force. pub fn set_timestamp(&mut self, timestamp: Option) { - self.timestamp_override = timestamp; + if self.timestamp_override != timestamp { + self.timestamp_override = timestamp; + self.block_env_source = None; + self.bump_snapshot_generation(); + } } /// Get the current timestamp override, if any. @@ -4045,12 +4118,12 @@ impl EvmCache { /// Get the base fee per gas used for EVM simulations (the `BASEFEE` opcode). /// /// Fetched from the pinned block's header at construction. `None` means - /// revm falls back to `0`. This is cleared by [`set_block`](Self::set_block) - /// / [`repin_to_block`](Self::repin_to_block) when the pin changes, and by + /// revm falls back to `0`. This and every other header-derived environment + /// field are cleared by [`set_block`](Self::set_block) / + /// [`repin_to_block`](Self::repin_to_block) when the pin changes, and by /// non-concrete tag/hash pin calls because those can drift without a - /// concrete number in the API. Refresh it with - /// [`set_block_context`](Self::set_block_context) after fetching a new header - /// if `BASEFEE` accuracy matters. + /// concrete number in the API. Prefer [`advance_block`](Self::advance_block) + /// to install a complete fetched header. pub fn basefee(&self) -> Option { self.basefee } @@ -4060,8 +4133,12 @@ impl EvmCache { /// Call this when the simulation block changes (e.g. at the start of each /// search cycle) to keep NUMBER and BASEFEE opcodes accurate. pub fn set_block_context(&mut self, block_number: Option, basefee: Option) { - self.block_number = block_number; - self.basefee = basefee; + if self.block_number != block_number || self.basefee != basefee { + self.block_number = block_number; + self.basefee = basefee; + self.block_env_source = None; + self.bump_snapshot_generation(); + } } /// Set the block base fee (the `BASEFEE` opcode) for subsequent simulations, @@ -4076,7 +4153,12 @@ impl EvmCache { /// The cache stores the base fee as a `u64` (matching the block header and the /// `EvmSnapshot` field), so a `U256` larger than `u64::MAX` is saturated. pub fn set_basefee(&mut self, basefee: U256) { - self.basefee = Some(basefee.saturating_to::()); + let basefee = Some(basefee.saturating_to::()); + if self.basefee != basefee { + self.basefee = basefee; + self.block_env_source = None; + self.bump_snapshot_generation(); + } } /// Override the block beneficiary (the `COINBASE` opcode) for subsequent @@ -4085,7 +4167,11 @@ impl EvmCache { /// Set this when simulating logic that reads `block.coinbase` (e.g. /// MEV/builder tip accounting). `None` lets revm use its default beneficiary. pub fn set_coinbase(&mut self, coinbase: Option
) { - self.coinbase = coinbase; + if self.coinbase != coinbase { + self.coinbase = coinbase; + self.block_env_source = None; + self.bump_snapshot_generation(); + } } /// Override `prevrandao` (the `PREVRANDAO` opcode, the post-merge header mix @@ -4094,7 +4180,11 @@ impl EvmCache { /// Set this when reproducing contracts that source on-chain randomness from /// `block.prevrandao`. `None` leaves revm's default in place. pub fn set_prevrandao(&mut self, prevrandao: Option) { - self.prevrandao = prevrandao; + if self.prevrandao != prevrandao { + self.prevrandao = prevrandao; + self.block_env_source = None; + self.bump_snapshot_generation(); + } } /// Override the block gas limit (the `GASLIMIT` opcode) for subsequent @@ -4103,7 +4193,11 @@ impl EvmCache { /// Set this when simulating logic that reads `block.gaslimit`. `None` lets /// revm use its default. pub fn set_block_gas_limit(&mut self, gas_limit: Option) { - self.block_gas_limit = gas_limit; + if self.block_gas_limit != gas_limit { + self.block_gas_limit = gas_limit; + self.block_env_source = None; + self.bump_snapshot_generation(); + } } /// Get the block beneficiary used for EVM simulations (the `COINBASE` @@ -4111,8 +4205,8 @@ impl EvmCache { /// /// Fetched from the pinned block's header at construction, refreshed by /// [`advance_block`](Self::advance_block), or overridden via - /// [`set_coinbase`](Self::set_coinbase). `None` means revm uses its default - /// beneficiary. + /// [`set_coinbase`](Self::set_coinbase). A [`set_block`](Self::set_block) + /// repin clears it. `None` means revm uses its default beneficiary. pub fn coinbase(&self) -> Option
{ self.coinbase } @@ -4122,8 +4216,9 @@ impl EvmCache { /// /// Fetched from the pinned block's header at construction, refreshed by /// [`advance_block`](Self::advance_block), or overridden via - /// [`set_prevrandao`](Self::set_prevrandao). `None` leaves revm's default in - /// place. + /// [`set_prevrandao`](Self::set_prevrandao). A + /// [`set_block`](Self::set_block) repin clears it. `None` leaves revm's + /// default in place. pub fn prevrandao(&self) -> Option { self.prevrandao } @@ -4132,8 +4227,9 @@ impl EvmCache { /// /// Fetched from the pinned block's header at construction, refreshed by /// [`advance_block`](Self::advance_block), or overridden via - /// [`set_block_gas_limit`](Self::set_block_gas_limit). `None` lets revm use - /// its default. + /// [`set_block_gas_limit`](Self::set_block_gas_limit). A + /// [`set_block`](Self::set_block) repin clears it. `None` lets revm use its + /// default. pub fn block_gas_limit(&self) -> Option { self.block_gas_limit } @@ -4146,7 +4242,10 @@ impl EvmCache { /// [`advance_block`](Self::advance_block) rejects a header missing a required /// field rather than silently defaulting it. pub fn set_block_context_requirements(&mut self, reqs: BlockContextRequirements) { - self.block_context_requirements = reqs; + if self.block_context_requirements != reqs { + self.block_context_requirements = reqs; + self.bump_snapshot_generation(); + } } /// Engine-driven per-block env refresh from a canonical block header. @@ -4184,6 +4283,7 @@ impl EvmCache { self.prevrandao = header.mix_hash(); self.block_gas_limit = Some(header.gas_limit()); self.timestamp_override = Some(header.timestamp()); + self.block_env_source = Some(BlockEnvSource::HeaderNumber(header.number())); // Advance every fetch path to the new height in lockstep with the env: // the SharedBackend lazy fallback (a miss must not serve state from the @@ -4200,13 +4300,130 @@ impl EvmCache { Ok(()) } + /// Advance a reactive cache using compact canonical identity when no full + /// header is available. + /// + /// The exact canonical hash pin keeps every later lazy provider read on the + /// event's block. `NUMBER` and an available timestamp are known from the + /// compact identity. Header-only fields are cleared unless `preserve_env` + /// proves this is another record for a block whose full header was already + /// installed. Like [`advance_block`](Self::advance_block), this is a forward + /// roll of the event-maintained view and deliberately does not discard the + /// cache's accumulated state. + #[cfg(feature = "reactive")] + pub(crate) fn advance_compact_block( + &mut self, + number: u64, + hash: B256, + timestamp: Option, + preserve_env: bool, + ) { + let block = BlockId::from((hash, Some(true))); + let preserve_verified_env = preserve_env + && match self.block_env_source { + Some(BlockEnvSource::HeaderNumber(env_number)) => env_number == number, + Some(BlockEnvSource::VerifiedHash { + number: env_number, + hash: env_hash, + }) => env_number == number && env_hash == hash, + None => false, + }; + // Timestamp is part of compact block identity, unlike the header-only + // fee/beneficiary/randomness/gas fields. Preserve an already-known + // timestamp for another partial record at the exact same hash even when + // it originated from compact progress rather than a full header. + let preserve_known_timestamp = + preserve_env && self.block == block && self.block_number == Some(number); + let next_timestamp = + timestamp.or(self.timestamp_override.filter(|_| preserve_known_timestamp)); + let mut changed = self.block != block + || self.block_number != Some(number) + || self.timestamp_override != next_timestamp; + if !preserve_verified_env { + changed |= self.basefee.is_some() + || self.coinbase.is_some() + || self.prevrandao.is_some() + || self.block_gas_limit.is_some(); + self.basefee = None; + self.coinbase = None; + self.prevrandao = None; + self.block_gas_limit = None; + self.block_env_source = None; + } else { + self.block_env_source = Some(BlockEnvSource::VerifiedHash { number, hash }); + } + self.block = block; + self.block_number = Some(number); + self.timestamp_override = next_timestamp; + let block_number = U256::from(number); + changed |= self + .blockchain_db + .block_hashes() + .write() + .insert(block_number, hash) + != Some(hash); + changed |= self.db.cache.block_hashes.insert(block_number, hash) != Some(hash); + let _ = self.backend.set_pinned_block(block); + if changed { + self.bump_snapshot_generation(); + } + } + + /// Forget every cached `BLOCKHASH` value at or above `from_block`. + /// + /// Foundry's backend cache is keyed only by block number; re-pinning it does + /// not invalidate hashes learned on a displaced branch. Reorg handling must + /// therefore clear both backend and revm-layer values before any replacement + /// branch handler or simulation can observe them. + #[cfg(feature = "reactive")] + pub(crate) fn invalidate_cached_block_hashes_from(&mut self, from_block: u64) { + let from_block = U256::from(from_block); + let mut changed = false; + { + let mut hashes = self.blockchain_db.block_hashes().write(); + let before = hashes.len(); + hashes.retain(|number, _| *number < from_block); + changed |= hashes.len() != before; + } + let before = self.db.cache.block_hashes.len(); + self.db + .cache + .block_hashes + .retain(|number, _| *number < from_block); + changed |= self.db.cache.block_hashes.len() != before; + if changed { + self.bump_snapshot_generation(); + } + } + + /// Install an exact canonical `BLOCKHASH` value in both cache layers. + /// + /// Reactive reorg recovery uses this after invalidating an unknown-parent + /// branch. The arriving child still proves the identity of `N - 1`, so the + /// stale value must be replaced before any handler can execute against the + /// replacement block. + #[cfg(feature = "reactive")] + pub(crate) fn set_cached_block_hash(&mut self, block_number: u64, hash: B256) { + let block_number = U256::from(block_number); + let mut changed = self + .blockchain_db + .block_hashes() + .write() + .insert(block_number, hash) + != Some(hash); + changed |= self.db.cache.block_hashes.insert(block_number, hash) != Some(hash); + if changed { + self.bump_snapshot_generation(); + } + } + /// Re-pin the cache to a specific block number. /// - /// Updates the SharedBackend pinned block, the batch fetcher block, and the - /// EVM block context (`NUMBER` opcode) in lockstep. The current `basefee` is - /// cleared because it cannot be refreshed synchronously; callers should set it - /// via [`set_block_context`](Self::set_block_context) after fetching the new - /// block header if `BASEFEE` accuracy matters. + /// Updates the SharedBackend pinned block and the EVM `NUMBER` context in + /// lockstep. All other block-header fields are cleared because they cannot + /// be refreshed synchronously; callers should prefer + /// [`advance_block`](Self::advance_block) when a complete new header is + /// available, or reinstall deliberate manual overrides after this call. pub fn repin_to_block(&mut self, block_number: u64) { let old_block = self.block; self.set_block(BlockId::Number(block_number.into())); diff --git a/src/cache/versioned.rs b/src/cache/versioned.rs index d6eac82..3f3a71d 100644 --- a/src/cache/versioned.rs +++ b/src/cache/versioned.rs @@ -1,3 +1,4 @@ +use bincode::Options; use serde::{Serialize, de::DeserializeOwned}; use tracing::warn; @@ -60,7 +61,45 @@ pub(crate) fn decode( return None; } - bincode::deserialize(&data[header_len..]) + let payload = &data[header_len..]; + let mut cursor = std::io::Cursor::new(payload); + let value = bincode::DefaultOptions::new() + .with_fixint_encoding() + .with_limit(payload.len() as u64) + .deserialize_from(&mut cursor) .inspect_err(|e| warn!(cache = label, error = %e, "Failed to parse cache payload")) - .ok() + .ok()?; + if cursor.position() != payload.len() as u64 { + warn!( + cache = label, + consumed = cursor.position(), + bytes = payload.len(), + "Cache payload has trailing bytes; treating as cache miss" + ); + return None; + } + Some(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAGIC: &[u8; 8] = b"VERSION\0"; + + #[test] + fn exact_payload_round_trips() { + let encoded = encode(MAGIC, 1, &vec![1_u64, 2, 3], "test").expect("encode"); + assert_eq!( + decode::>(&encoded, MAGIC, 1, "test"), + Some(vec![1, 2, 3]) + ); + } + + #[test] + fn trailing_payload_bytes_are_rejected() { + let mut encoded = encode(MAGIC, 1, &42_u64, "test").expect("encode"); + encoded.push(0xff); + assert_eq!(decode::(&encoded, MAGIC, 1, "test"), None); + } } diff --git a/src/freshness.rs b/src/freshness.rs index 0cd4556..36b1c20 100644 --- a/src/freshness.rs +++ b/src/freshness.rs @@ -169,7 +169,7 @@ impl FreshnessParams { /// /// Resolution precedence is **slot ▸ account ▸ default** (see /// [`FreshnessRegistry::validity`]). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Validity { /// Caller-owned: immutable, or kept fresh out-of-band (e.g. via event /// writes). The freshness system never re-verifies or purges it. @@ -190,7 +190,7 @@ pub enum Validity { /// changed via [`with_default`](Self::with_default)). /// /// The setters are builder-style (`&mut Self`) so they can be chained. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct FreshnessRegistry { default: Validity, accounts: HashMap, @@ -257,6 +257,24 @@ impl FreshnessRegistry { self.set_slot(addr, slot, Validity::ValidThrough(n)) } + /// Invalidate event-derived validity horizons from a dropped canonical + /// block onward while preserving caller-owned pinning and older horizons. + #[cfg(feature = "reactive")] + pub(crate) fn invalidate_valid_through_from(&mut self, first_dropped_block: u64) { + let invalidate = |validity: &mut Validity| { + if matches!(validity, Validity::ValidThrough(block) if *block >= first_dropped_block) { + *validity = Validity::Volatile; + } + }; + invalidate(&mut self.default); + for validity in self.accounts.values_mut() { + invalidate(validity); + } + for validity in self.slots.values_mut() { + invalidate(validity); + } + } + /// Set the account-level validity for `addr`. pub fn set_account(&mut self, addr: Address, validity: Validity) -> &mut Self { self.accounts.insert(addr, validity); diff --git a/src/lib.rs b/src/lib.rs index ddafefa..407fdcb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -176,10 +176,12 @@ pub use bundle::{BundleOptions, BundleResult, BundleTx, RevertPolicy, TxOutcome} pub use cache::{ AccountFieldsFetchFn, AccountProof, AccountProofFetchFn, BlockContextRequirements, BlockStateAccountDiff, BlockStateDiff, BlockStateDiffFetchFn, BlockStateStorageDiff, - CacheSpeedMode, CallSimulationResult, CodeMismatch, CodeSeedState, CodeVerifyReport, EvmCache, - EvmCacheBuilder, EvmOverlay, EvmSnapshot, PrewarmReport, StorageBatchConfig, - StorageFetchStrategy, TxConfig, account_proof_fetcher, point_read_storage_fetcher, - provider_storage_fetcher, + CacheSpeedMode, CallSimulationResult, CodeMismatch, CodeSeedState, CodeVerifyReport, + DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES, DurableCheckpointBlock, DurableCheckpointError, + DurableCheckpointIdentity, DurableCheckpointMetadata, DurableCheckpointStore, EvmCache, + EvmCacheBuilder, EvmOverlay, EvmSnapshot, LoadedDurableCheckpoint, PrewarmReport, + StorageBatchConfig, StorageFetchStrategy, TxConfig, account_proof_fetcher, + point_read_storage_fetcher, provider_storage_fetcher, }; #[cfg(feature = "reactive")] pub use cold_start::{ @@ -214,8 +216,9 @@ pub use mapping_probe::{ }; #[cfg(feature = "reactive")] pub use reactive::{ - InterestOwnerSubscriber, ReactiveConfig, ReactiveEngine, ReactiveEngineError, - ReactiveEngineRegisterError, ReactiveHandler, ReactiveRuntime, + CheckpointedIngest, InterestOwnerSubscriber, ReactiveBaselineError, ReactiveCanonicalBaseline, + ReactiveCheckpointRestoreError, ReactiveConfig, ReactiveEngine, ReactiveEngineError, + ReactiveEngineRegisterError, ReactiveHandler, ReactiveRuntime, SubscriberPayloadCommitment, }; pub use state_update::{ AccountChange, AccountPatch, PurgeRecord, PurgeScope, SkippedAccountPatch, SkippedBalanceDelta, diff --git a/src/mapping_probe.rs b/src/mapping_probe.rs index a1de861..d7cd601 100644 --- a/src/mapping_probe.rs +++ b/src/mapping_probe.rs @@ -57,7 +57,7 @@ const MAX_PREIMAGE_LEN: usize = 4096; // =========================================================================== /// The storage layout a [`HashSlotAccess`] was factored into. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum SlotLayout { /// `keccak256(key ‖ slot)` — Solidity's `mapping` layout. SolidityMapping, @@ -165,7 +165,7 @@ impl HashSlotAccess { /// [`EvmCache::discover_erc20_balance_slot`](crate::cache::EvmCache::discover_erc20_balance_slot)), /// then call [`slot_for`](Self::slot_for) for each key you want to track — no /// re-simulation required. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct TrackedMapping { /// The contract whose storage holds the mapping. pub contract: Address, diff --git a/src/reactive/mod.rs b/src/reactive/mod.rs index 0ed22fb..0e25ac1 100644 --- a/src/reactive/mod.rs +++ b/src/reactive/mod.rs @@ -20,6 +20,7 @@ use std::{ hash::Hash, marker::PhantomData, num::NonZeroU64, + path::PathBuf, pin::Pin, sync::{ Arc, @@ -37,18 +38,23 @@ use alloy_network::{ TransactionResponse as TransactionResponseTrait, }, }; -use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_primitives::{Address, B256, Bytes, FixedBytes, Keccak256, U256}; use alloy_provider::Provider; use alloy_rpc_types_eth::{Filter, FilterSet, Log}; -#[cfg(any(feature = "reactive-ws", feature = "reactive-polling", test))] +pub use alloy_transport_balancer::EndpointId; +use bincode::Options; use futures::{StreamExt, stream}; use futures::{ - future::{Either, poll_fn, select, try_join_all}, + future::{Either, poll_fn, select}, stream::BoxStream, }; use crate::{ - cache::{AccountProof, BlockStateDiff, EvmCache}, + cache::{ + AccountProof, BlockStateDiff, DurableCheckpointBlock, DurableCheckpointError, + DurableCheckpointIdentity, DurableCheckpointMetadata, DurableCheckpointStore, EvmCache, + EvmCacheStateSnapshot, LoadedDurableCheckpoint, + }, errors::{BlockContextError, StorageFetchResult}, events::{EventDecoder, StateView}, freshness::FreshnessRegistry, @@ -71,7 +77,7 @@ pub enum ReactiveInput { } /// Context supplied with each [`ReactiveInput`]. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ReactiveContext { /// Chain id, when known. pub chain_id: Option, @@ -88,7 +94,7 @@ pub struct ReactiveContext { } /// Minimal block identity carried through reports. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct BlockRef { /// Block number. pub number: u64, @@ -100,11 +106,415 @@ pub struct BlockRef { pub timestamp: Option, } -/// Lifecycle status for an input. +/// Stable provider identity attached to provider-originated input. +/// +/// `generation` changes whenever a caller replaces or reconnects the concrete +/// provider session behind the same configured endpoint. Follow-up reads can +/// use this value to prefer the exact source that announced speculative state +/// without putting URLs or credentials into event payloads. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct ProviderRef { + /// Operator-defined endpoint identity. + pub endpoint: EndpointId, + /// Concrete connection/session generation. + pub generation: u64, +} + +impl ProviderRef { + /// Construct provider provenance for one connection generation. + pub fn new(endpoint: impl Into, generation: u64) -> Self { + Self { + endpoint: endpoint.into(), + generation, + } + } +} + +/// Identity of one cumulative pre-confirmed Flashblock snapshot. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct FlashblockRef { + /// Provider session that supplied this snapshot. + pub provider: ProviderRef, + /// Sequencer payload id shared by every Flashblock in the full block. + /// + /// OP RPCs that expose only the standard pending block surface may not + /// expose this Base-native identifier. + pub payload_id: Option>, + /// Zero-based Flashblock index, when exposed by the endpoint. + pub index: Option, + /// Pending block number represented by this cumulative snapshot. + pub block_number: u64, + /// Hash of the cumulative partial block at this snapshot. + pub block_hash: B256, + /// Canonical parent of the pending block, when exposed. + pub parent_hash: Option, + /// State root after this cumulative snapshot, when exposed. + pub state_root: Option, + /// Pending block timestamp, when exposed. + pub timestamp: Option, +} + +impl FlashblockRef { + /// Convert the pre-confirmed identity into the block metadata used by + /// ordinary log routing. The hash is explicitly a partial/pending hash and + /// must not advance canonical coverage. + pub const fn block_ref(&self) -> BlockRef { + BlockRef { + number: self.block_number, + hash: self.block_hash, + parent_hash: self.parent_hash, + timestamp: self.timestamp, + } + } + + fn same_payload(&self, other: &Self) -> bool { + self.provider == other.provider + && match (self.payload_id, other.payload_id) { + (Some(left), Some(right)) => left == right, + _ => { + self.block_number == other.block_number && self.parent_hash == other.parent_hash + } + } + } +} + +/// Whether the subscriber may use Flashblocks for speculative delivery. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum PreconfirmationMode { + /// Use only canonical subscription/polling behavior. + #[default] + Disabled, + /// Prefer Flashblocks, but retain canonical operation when the selected + /// chain/provider cannot establish the pre-confirmation stream. + Preferred, + /// Fail setup/reconnect closed unless Flashblocks can be established. + Required, +} + +/// Base-native `newFlashblocks` subscription payload. +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +pub struct BaseFlashblockPayload { + /// Block-builder payload id shared by every incremental snapshot. + pub payload_id: FixedBytes<8>, + /// Zero-based incremental snapshot index. + pub index: u64, + /// Header fields present on index zero. + pub base: Option, + /// Cumulative state commitments for this snapshot. + pub diff: BaseFlashblockDiff, + /// Supplemental block identity retained across current Base versions. + #[serde(default)] + pub metadata: Option, +} + +/// Stable index-zero header subset from Base's Flashblocks wire format. +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +pub struct BaseFlashblockBase { + /// Canonical parent block hash. + pub parent_hash: B256, + /// Pending block number. + #[serde(deserialize_with = "deserialize_rpc_u64")] + pub block_number: u64, + /// Pending block timestamp. + #[serde(deserialize_with = "deserialize_rpc_u64")] + pub timestamp: u64, +} + +/// Stable commitment subset from Base's Flashblocks wire format. +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +pub struct BaseFlashblockDiff { + /// State root after this cumulative snapshot. + pub state_root: B256, + /// Partial block hash after this cumulative snapshot. + pub block_hash: B256, +} + +/// Stable metadata subset used when index-greater-than-zero payloads omit the +/// Base header object. +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +pub struct BaseFlashblockMetadata { + /// Pending block number (currently encoded as a JSON integer). + #[serde(deserialize_with = "deserialize_rpc_u64")] + pub block_number: u64, +} + +/// Current Base/QuickNode `newFlashblocks` wire shape. The endpoint emits a +/// cumulative block-shaped snapshot for every partial block update. +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct BaseFlashblockBlockPayload { + hash: B256, + #[serde(deserialize_with = "deserialize_rpc_u64")] + number: u64, + parent_hash: B256, + state_root: B256, + #[serde(deserialize_with = "deserialize_rpc_u64")] + timestamp: u64, +} + +/// Base has exposed both an indexed diff envelope and a cumulative +/// block-shaped envelope for `newFlashblocks`. Accept both so provider rollout +/// differences do not force callers onto separate subscriber paths. +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(untagged)] +enum BaseFlashblockWirePayload { + Indexed(BaseFlashblockPayload), + Block(BaseFlashblockBlockPayload), +} + +fn deserialize_rpc_u64<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum RpcU64 { + Number(u64), + String(String), + } + + match ::deserialize(deserializer)? { + RpcU64::Number(number) => Ok(number), + RpcU64::String(value) => { + let value = value.strip_prefix("0x").unwrap_or(&value); + u64::from_str_radix(value, 16).map_err(serde::de::Error::custom) + } + } +} + +/// Exact chain/block identity of an RPC cache snapshot adopted as the starting +/// point for reactive event continuity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ReactiveCanonicalBaseline { + /// Chain whose state the cache snapshot contains. + pub chain_id: u64, + /// Canonical block through which the snapshot already embodies state. + pub block: BlockRef, +} + +impl ReactiveCanonicalBaseline { + /// Construct an exact cache snapshot baseline. + pub const fn new(chain_id: u64, block: BlockRef) -> Self { + Self { chain_id, block } + } +} + +/// Ordered chain-lifecycle control delivered by an event subscriber. +/// +/// Controls live inside [`ReactiveInputBatch`] so they share the same delivery +/// token, durable checkpoint, and ordering guarantees as ordinary event data. +/// Reorg controls are applied in declaration order before replacement records; +/// progress, barrier, safe, and finalized controls are committed in declaration +/// order after the records. A reorg declared after a post-record control is +/// rejected because its ordering would otherwise be ambiguous. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub enum ChainControl { + /// Replace the old canonical branch after `common_ancestor` with `new_tip`. + Reorg { + /// Last block common to the old and new canonical branches. + common_ancestor: BlockRef, + /// Tip of the branch that ceased to be canonical. + old_tip: BlockRef, + /// Tip of the newly canonical branch known by the source. + new_tip: BlockRef, + }, + /// Update the source's safe head. + Safe(BlockRef), + /// Update the source's finalized head. + Finalized(BlockRef), + /// Advance authoritative canonical coverage without fabricating a full header. + /// + /// Indexers that only know compact block identity should emit this control. + /// It never runs block handlers. The runtime exact-hash pins provider reads + /// and installs known `NUMBER`/timestamp values, but clears unproven + /// header-only environment fields such as base fee and beneficiary. + CanonicalProgress(BlockRef), + /// Ordered cutover or synchronization fence. + Barrier { + /// Subscriber-defined opaque barrier identity. + id: Vec, + /// Highest canonical event block included before the fence, if known. + block: Option, + }, +} + +/// Provider-neutral snapshot consumed by [`validate_canonical_sequence`]. +/// +/// Composite subscribers can persist this small chain-state view beside their +/// own delivery checkpoint and validate a complete delivery envelope before it +/// reaches a [`ReactiveRuntime`]. The retained history may be sparse (blocks +/// without matching events need not be present), but it must contain at most +/// one compatible identity per height. Its oldest entry is also the durable +/// rollback horizon: an unretained explicit ancestor is accepted only when that +/// oldest entry is at or below the ancestor. This type carries no cache data, +/// event payloads, handler state, or transport-specific cursor. +/// +/// The serde representation is a convenience for caller-owned persistence; it +/// is not a versioned wire or checkpoint format. Durable protocols should wrap +/// it in their own versioned envelope and define migrations before upgrading +/// this pre-1.0 crate. External callers also own retention: successful +/// validation appends canonical identities but does not silently discard the +/// rollback proof window. Bound it with [`Self::retain_recent_history`] after +/// committing the matching source cursor/ACK. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CanonicalSequenceState { + retained_canonical_history: Vec, + coverage_head: Option, + safe_head: Option, + finalized_head: Option, +} + +impl CanonicalSequenceState { + /// Construct a validation snapshot from retained canonical metadata. + /// + /// Construction does not validate ordering, adjacency, coverage, or + /// finality invariants. Call [`Self::validate`] before installing decoded or + /// externally assembled state. + pub fn new( + retained_canonical_history: Vec, + coverage_head: Option, + safe_head: Option, + finalized_head: Option, + ) -> Self { + Self { + retained_canonical_history, + coverage_head, + safe_head, + finalized_head, + } + } + + /// Sparse retained canonical history in ascending processing order. + pub fn retained_canonical_history(&self) -> &[BlockRef] { + &self.retained_canonical_history + } + + /// Highest canonical identity covered by this state, when known. + pub const fn coverage_head(&self) -> Option<&BlockRef> { + self.coverage_head.as_ref() + } + + /// Latest safe head accepted by the validator, when known. + pub const fn safe_head(&self) -> Option<&BlockRef> { + self.safe_head.as_ref() + } + + /// Latest finalized head accepted by the validator, when known. + pub const fn finalized_head(&self) -> Option<&BlockRef> { + self.finalized_head.as_ref() + } + + /// Retain at most the newest `max_entries` canonical history identities. + /// + /// Coverage and safe/finalized heads are unchanged. The oldest retained + /// identity defines how far strict validation can prove a complete + /// rollback, so choose a bound at least as large as the deployment's + /// supported reorg depth and trim only after atomically committing the + /// corresponding validated state and source cursor. `0` intentionally + /// produces a coverage-only snapshot. + pub fn retain_recent_history(&mut self, max_entries: usize) { + let remove = self + .retained_canonical_history + .len() + .saturating_sub(max_entries); + self.retained_canonical_history.drain(..remove); + } + + /// Validate a decoded/checkpointed snapshot before installing it. + /// + /// This rejects out-of-order or conflicting retained identities, + /// broken adjacent parent links, retained history without coverage, + /// incompatible coverage/finality aliases, hash reuse across heights, + /// known parent hashes at non-adjacent heights, finality beyond coverage, + /// and a finalized head beyond or conflicting with the safe head. + /// + /// # Errors + /// + /// Returns [`ReactiveError`] when any retained identity, parent link, + /// coverage alias, or safe/finalized relationship violates the canonical + /// snapshot invariants described above. + pub fn validate(&self) -> Result<(), ReactiveError> { + validate_canonical_sequence_snapshot(self) + } +} + +/// Cache-free canonical transition proven by [`validate_canonical_sequence`]. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum CanonicalSequenceMutation { + /// Rewind the listed retained identities and continue from `common_ancestor`. + Rewind { + /// Surviving canonical anchor, when one is retained or authenticated. + /// `None` is a transient same-envelope state: callers must stage the + /// complete validation atomically and may checkpoint only the returned + /// `next_state`, after a later canonical mutation installs the proven + /// replacement. + common_ancestor: Option, + /// Exact retained identities removed by the transition. + dropped: Vec, + }, + /// Accept or enrich one canonical identity. + Canonical(BlockRef), + /// Accept a safe-head update with metadata resolved against prior state. + Safe(BlockRef), + /// Accept a finalized-head update with metadata resolved against prior state. + Finalized(BlockRef), +} + +/// Successful result of provider-neutral canonical envelope validation. #[derive(Clone, Debug, PartialEq, Eq)] +pub struct CanonicalSequenceValidation { + pre_record_state: CanonicalSequenceState, + next_state: CanonicalSequenceState, + mutations: Vec, + normalized_chain_controls: Vec, +} + +impl CanonicalSequenceValidation { + /// State after pre-record explicit reorg controls and before event records. + pub const fn pre_record_state(&self) -> &CanonicalSequenceState { + &self.pre_record_state + } + + /// Fully validated state after records and post-record controls. + pub const fn next_state(&self) -> &CanonicalSequenceState { + &self.next_state + } + + /// Ordered cache-free canonical mutations proven by this envelope. + pub fn mutations(&self) -> &[CanonicalSequenceMutation] { + &self.mutations + } + + /// Controls safe to forward after composite overlap normalization. + /// + /// Ordinary validation retains the original controls. See + /// [`normalize_and_validate_canonical_sequence`] for the mode that removes + /// compatible stale progress and converts a stale blockful barrier into the + /// same barrier identity without a block assertion. Equal-height controls + /// that add previously absent parent/timestamp metadata remain present; + /// older compatible enrichment is intentionally not applied because the + /// corresponding regressive control is not forwarded to the runtime. + pub fn normalized_chain_controls(&self) -> &[ChainControl] { + &self.normalized_chain_controls + } +} + +/// Lifecycle status for an input. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] pub enum ChainStatus { /// The input is mempool-only and must not mutate canonical cache state. Pending, + /// The input is ordered into an ephemeral sequencer-built Flashblock. + /// + /// Handlers may update the runtime's speculative overlay for this status, + /// but the update never advances canonical coverage or durable journals. + Preconfirmed { + /// Exact cumulative pre-confirmation snapshot observed by the source. + flashblock: FlashblockRef, + }, /// The input is included in a block with a confirmation count. Included { /// Included block. @@ -130,7 +540,8 @@ pub enum ChainStatus { } /// Source of an input batch. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] pub enum InputSource { /// Caller-supplied batch. Batch, @@ -140,12 +551,14 @@ pub enum InputSource { Poll, /// Historical backfill. Backfill, + /// Sequencer pre-confirmation / Flashblocks surface. + Flashblocks, /// Test or synthetic input. Synthetic, } /// Stable identity used for input deduplication and reports. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum InputRef { /// Stable log identity. Log { @@ -176,6 +589,110 @@ pub enum InputRef { }, } +/// Representation and lifecycle class retained alongside an [`InputRef`]. +/// +/// `InputRef` identifies the underlying chain object. This discriminator keeps +/// distinct handler inputs from collapsing merely because they commit to the +/// same object: a header and full block, a pending hash and hydrated body, and +/// canonical versus reorg-signalling log delivery are independently routable. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub enum ReactiveInputKind { + /// Canonical log data. + CanonicalLog, + /// Removed or otherwise reorg-signalling log data. + ReorgSignalLog, + /// Header-only block representation. + BlockHeader, + /// Full block representation. + FullBlock, + /// Hash-only pending transaction representation. + PendingTxHash, + /// Hydrated pending transaction representation. + PendingTx, +} + +/// Validated, representation-aware identity for one reactive input. +/// +/// Composite subscribers can use this as a dedupe key without conflating +/// independently routable representations. When a key repeats, use +/// [`ReactiveInputRecord::same_deduplicable_payload`] to distinguish a true +/// provider overlap from a conflicting payload. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct ReactiveInputIdentity { + input_ref: InputRef, + kind: ReactiveInputKind, +} + +impl ReactiveInputIdentity { + /// Validate and construct an identity from explicit wire/codec parts. + /// + /// `InputRef` identifies the underlying object, while `kind` identifies its + /// representation/lifecycle. Only log kinds may pair with [`InputRef::Log`], + /// block representations with [`InputRef::Block`], and pending-transaction + /// representations with [`InputRef::PendingTx`]. This constructor lets + /// external codecs rebuild the otherwise-private invariant without serde or + /// layout-dependent decoding. + /// + /// # Errors + /// + /// Returns [`ReactiveInputIdentityError`] when `input_ref` does not belong + /// to the supplied representation `kind`. + pub fn try_from_parts( + input_ref: InputRef, + kind: ReactiveInputKind, + ) -> Result { + let compatible = matches!( + (input_ref, kind), + ( + InputRef::Log { .. }, + ReactiveInputKind::CanonicalLog | ReactiveInputKind::ReorgSignalLog + ) | ( + InputRef::Block { .. }, + ReactiveInputKind::BlockHeader | ReactiveInputKind::FullBlock + ) | ( + InputRef::PendingTx { .. }, + ReactiveInputKind::PendingTxHash | ReactiveInputKind::PendingTx + ) + ); + if !compatible { + return Err(ReactiveInputIdentityError { input_ref, kind }); + } + Ok(Self { input_ref, kind }) + } + + /// Underlying stable chain-object reference. + pub const fn input_ref(&self) -> InputRef { + self.input_ref + } + + /// Exact handler-input representation and lifecycle class. + pub const fn kind(&self) -> ReactiveInputKind { + self.kind + } +} + +/// An explicit [`InputRef`] and [`ReactiveInputKind`] describe incompatible +/// object/representation classes. +#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)] +#[error("reactive input kind {kind:?} is incompatible with input reference {input_ref:?}")] +pub struct ReactiveInputIdentityError { + input_ref: InputRef, + kind: ReactiveInputKind, +} + +impl ReactiveInputIdentityError { + /// Rejected stable object reference. + pub const fn input_ref(&self) -> InputRef { + self.input_ref + } + + /// Rejected representation/lifecycle kind. + pub const fn kind(&self) -> ReactiveInputKind { + self.kind + } +} + /// Reliability of state effects emitted by a handler. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum StateEffectQuality { @@ -192,13 +709,32 @@ pub enum StateEffectQuality { } /// Identifier for a reactive handler. -#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)] pub struct HandlerId(String); impl HandlerId { - /// Create a handler id. + /// Create a non-empty handler id. + /// + /// # Panics + /// + /// Panics when `id` is empty. Use [`try_new`](Self::try_new) for untrusted + /// configuration or wire input. pub fn new(id: impl Into) -> Self { - Self(id.into()) + Self::try_new(id).expect("handler id must not be empty") + } + + /// Validate and create a handler id from untrusted input. + /// + /// # Errors + /// + /// Returns [`HandlerIdError`] when `id` is empty. The empty identity is + /// reserved for canonical/global protocol scope. + pub fn try_new(id: impl Into) -> Result { + let id = id.into(); + if id.is_empty() { + return Err(HandlerIdError); + } + Ok(Self(id)) } /// Return the id as a string slice. @@ -207,6 +743,22 @@ impl HandlerId { } } +impl<'de> serde::Deserialize<'de> for HandlerId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let id = ::deserialize(deserializer)?; + Self::try_new(id).map_err(serde::de::Error::custom) + } +} + +/// An empty handler identity cannot be represented portably across subscriber +/// protocols because the empty owner is reserved for canonical/global scope. +#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)] +#[error("handler id must not be empty")] +pub struct HandlerIdError; + impl fmt::Display for HandlerId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) @@ -300,6884 +852,15942 @@ pub struct ReactiveInputRecord { pub input: ReactiveInput, /// Input context. pub context: ReactiveContext, + /// Provider session that originated this input, when it came from a + /// concrete provider rather than a synthetic or aggregate source. + pub provider: Option, } impl ReactiveInputRecord { /// Create an input record. pub fn new(input: ReactiveInput, context: ReactiveContext) -> Self { - Self { input, context } + Self { + input, + context, + provider: None, + } + } + + /// Attach provider provenance used to route follow-up reads. + #[must_use] + pub fn with_provider(mut self, provider: ProviderRef) -> Self { + self.provider = Some(provider); + self } /// Compute the stable input reference used for deduplication. pub fn input_ref(&self) -> InputRef { input_ref(&self.input, &self.context) } -} - -/// Batch of reactive input records. -#[derive(Clone, Debug)] -pub struct ReactiveInputBatch { - records: Vec>, -} -impl ReactiveInputBatch { - /// Create a batch from records. - pub fn new(records: Vec>) -> Self { - Self { records } + /// Validate payload/context coherence and return a representation-aware + /// identity suitable for subscriber and runtime deduplication. + /// + /// Validation is fail-closed for canonical logs: their block, transaction, + /// and log positions must be complete and agree with the context. Block and + /// pending-transaction representations receive the corresponding lifecycle, + /// inclusion-wrapper, and payload/context checks. This does not recompute a + /// claimed header hash, transaction root, or transaction signature; exact + /// subscriber payload commitments remain the transport-integrity boundary + /// for those cryptographic claims. + /// + /// # Errors + /// + /// Returns [`ReactiveError::InvalidInputRecord`] when the payload, + /// lifecycle, inclusion metadata, or context is incomplete or internally + /// inconsistent. + pub fn validated_identity(&self) -> Result { + validate_input_record(self)?; + let kind = match &self.input { + ReactiveInput::Log(log) + if log.removed + || matches!(self.context.chain_status, ChainStatus::Reorged { .. }) => + { + ReactiveInputKind::ReorgSignalLog + } + ReactiveInput::Log(_) => ReactiveInputKind::CanonicalLog, + ReactiveInput::BlockHeader(_) => ReactiveInputKind::BlockHeader, + ReactiveInput::FullBlock(_) => ReactiveInputKind::FullBlock, + ReactiveInput::PendingTxHash(_) => ReactiveInputKind::PendingTxHash, + ReactiveInput::PendingTx(_) => ReactiveInputKind::PendingTx, + }; + ReactiveInputIdentity::try_from_parts(self.input_ref(), kind).map_err(|error| { + ReactiveError::InvalidInputRecord { + message: error.to_string(), + } + }) } - /// Borrow the records in this batch. - pub fn records(&self) -> &[ReactiveInputRecord] { - &self.records + /// Whether two same-identity records carry the same deduplicable payload. + /// + /// This deliberately ignores [`ReactiveContext`]: the same provider object + /// can legitimately arrive from backfill and subscription transports with + /// different provenance or confirmation metadata. Callers must first + /// compare [`validated_identity`](Self::validated_identity) and reconcile + /// lifecycle/context authority separately. Logs are compared structurally; + /// block and transaction hashes are cryptographic commitments for the + /// remaining same-representation payloads. Full block responses and + /// hydrated pending transaction bodies deliberately return `false`: the + /// core does not currently prove a supplied body against the header's + /// transaction root or compare every response field, so a composite source + /// must preserve both rather than suppress one based only on its hash. + pub fn same_deduplicable_payload(&self, other: &Self) -> bool { + match (&self.input, &other.input) { + (ReactiveInput::Log(left), ReactiveInput::Log(right)) => { + left.inner == right.inner + && left.block_hash == right.block_hash + && left.block_number == right.block_number + && optional_metadata_compatible( + left.block_timestamp.as_ref(), + right.block_timestamp.as_ref(), + ) + && left.transaction_hash == right.transaction_hash + && left.transaction_index == right.transaction_index + && left.log_index == right.log_index + && left.removed == right.removed + } + (ReactiveInput::BlockHeader(left), ReactiveInput::BlockHeader(right)) => { + left.hash() == right.hash() + } + (ReactiveInput::FullBlock(_), ReactiveInput::FullBlock(_)) => false, + (ReactiveInput::PendingTxHash(left), ReactiveInput::PendingTxHash(right)) => { + left == right + } + (ReactiveInput::PendingTx(_), ReactiveInput::PendingTx(_)) => false, + _ => false, + } } - /// Consume the batch into its records. - pub fn into_records(self) -> Vec> { - self.records + /// Whether this representation has a complete payload-equivalence contract + /// and may participate in duplicate suppression. + /// + /// Full block and hydrated pending transaction bodies are intentionally + /// excluded until their complete body/response integrity is validated. + pub fn is_payload_deduplicable(&self) -> bool { + matches!( + &self.input, + ReactiveInput::Log(_) | ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) + ) } -} - -/// Pure synchronous handler for reactive inputs. -pub trait ReactiveHandler: Send + Sync { - /// Stable handler id. - fn id(&self) -> HandlerId; - - /// Interests used by subscribers and the local router. - fn interests(&self) -> Vec>; - /// Exhaustive exact keys for log inputs this handler can accept. + /// Merge `other` when it is the same safely deduplicable provider object. /// - /// Returning `None` keeps the handler on the compatibility fallback path. - /// Returning an index promises that every matching log has at least one of - /// its keys; the registry still re-checks the handler's original - /// [`LogInterest`]s and local matchers before dispatch. - fn log_route_index(&self) -> Option { - None + /// Returns `Ok(false)` for a different identity or a representation whose + /// complete payload cannot be proven equivalent. A same-identity payload or + /// semantic conflict returns an error. Successful merges are deterministic: + /// optional block/timestamp metadata is enriched, canonical lifecycle moves + /// toward `Finalized` then `Safe` then the highest-confirmation `Included`, + /// and provenance uses a stable source priority. The result is therefore + /// independent of historical/live arrival order. + /// + /// # Errors + /// + /// Returns [`ReactiveError`] when either record is invalid, or when equal + /// identities carry conflicting payload or semantic context. + pub fn merge_compatible_duplicate(&mut self, other: &Self) -> Result { + let identity = self.validated_identity()?; + let other_identity = other.validated_identity()?; + if identity != other_identity + || !self.is_payload_deduplicable() + || !other.is_payload_deduplicable() + { + return Ok(false); + } + if !self.same_deduplicable_payload(other) || !self.dedupe_context_is_compatible(other) { + return Err(ReactiveError::InvalidInputRecord { + message: format!( + "conflicting payload or semantic context for identity {:?}", + identity + ), + }); + } + let mut merged = self.clone(); + merge_deduplicable_record(&mut merged, other); + merged.validated_identity()?; + *self = merged; + Ok(true) } - /// Handle one input against a read-only cache view. - fn handle( - &self, - ctx: &ReactiveContext, - input: &ReactiveInput, - state: &dyn StateView, - ) -> Result; + /// Whether semantic context agrees for deduplication across transports. + /// + /// Provenance source and confirmation count may legitimately differ at a + /// historical/live overlap and are ignored. Chain id, lifecycle class, and + /// transaction/log positions must agree. Block number/hash are exact; + /// optional parent/timestamp metadata may be enriched by one source but two + /// present conflicting values are rejected. + pub fn dedupe_context_is_compatible(&self, other: &Self) -> bool { + let left = &self.context; + let right = &other.context; + left.chain_id == right.chain_id + && optional_block_refs_are_compatible(left.block.as_ref(), right.block.as_ref()) + && left.transaction_index == right.transaction_index + && left.log_index == right.log_index + && chain_statuses_are_dedupe_compatible(&left.chain_status, &right.chain_status) + } } -/// Hook invoked after reports are built and cache mutation phases have ended. -pub trait ReactiveHook: Send + Sync { - /// Observe a runtime report. - fn on_report(&self, report: Arc>); +fn chain_statuses_are_dedupe_compatible(left: &ChainStatus, right: &ChainStatus) -> bool { + match (left, right) { + (ChainStatus::Pending, ChainStatus::Pending) + | (ChainStatus::Reorged { .. }, ChainStatus::Reorged { .. }) => true, + ( + ChainStatus::Preconfirmed { flashblock: left }, + ChainStatus::Preconfirmed { flashblock: right }, + ) => left == right, + ( + ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }, + ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. }, + ) => true, + _ => false, + } } -/// Reactive subscription interest. -#[allow(clippy::large_enum_variant)] -#[derive(Clone)] -pub enum ReactiveInterest { - /// Log interest. - Logs(LogInterest), - /// Block interest. - Blocks(BlockInterest), - /// Pending transaction interest. - PendingTransactions(PendingTxInterest), +fn optional_metadata_compatible(left: Option<&T>, right: Option<&T>) -> bool { + left.zip(right).is_none_or(|(left, right)| left == right) } -impl fmt::Debug for ReactiveInterest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(), - Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(), - Self::PendingTransactions(interest) => f - .debug_tuple("PendingTransactions") - .field(interest) - .finish(), +fn optional_block_refs_are_compatible(left: Option<&BlockRef>, right: Option<&BlockRef>) -> bool { + match (left, right) { + (None, None) => true, + (Some(left), Some(right)) => { + left.number == right.number + && left.hash == right.hash + && optional_metadata_compatible( + left.parent_hash.as_ref(), + right.parent_hash.as_ref(), + ) + && optional_metadata_compatible(left.timestamp.as_ref(), right.timestamp.as_ref()) } + _ => false, } } -/// Interest in logs. -#[derive(Clone)] -pub struct LogInterest { - /// Provider-side filter. - pub provider_filter: Filter, - /// Optional local matcher for predicates providers cannot express. - pub local_matcher: Option>, - /// Optional route-key extraction strategy. - pub route_key: Option, -} - -impl LogInterest { - /// Return true if the log matches both the provider filter and local matcher. - pub fn matches(&self, log: &Log) -> bool { - self.provider_filter.rpc_matches(log) - && self - .local_matcher - .as_ref() - .is_none_or(|matcher| matcher.matches(log)) +fn merge_deduplicable_record( + retained: &mut ReactiveInputRecord, + incoming: &ReactiveInputRecord, +) { + if let (ReactiveInput::Log(retained), ReactiveInput::Log(incoming)) = + (&mut retained.input, &incoming.input) + && retained.block_timestamp.is_none() + { + retained.block_timestamp = incoming.block_timestamp; } - - /// Extract the route key for a matching log, if configured. - pub fn route_key(&self, log: &Log) -> Option { - self.route_key.as_ref().and_then(|spec| spec.extract(log)) + if let (Some(retained), Some(incoming)) = + (&mut retained.context.block, incoming.context.block.as_ref()) + { + enrich_block_ref(retained, incoming); } -} - -impl fmt::Debug for LogInterest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("LogInterest") - .field("provider_filter", &self.provider_filter) - .field( - "local_matcher", - &self.local_matcher.as_ref().map(|_| ""), - ) - .field("route_key", &self.route_key) - .finish() + retained.context.chain_status = merged_chain_status( + &retained.context.chain_status, + &incoming.context.chain_status, + ); + if input_source_rank(incoming.context.source) > input_source_rank(retained.context.source) { + retained.context.source = incoming.context.source; + } + if retained.provider.is_none() { + retained.provider = incoming.provider.clone(); } } -/// Local log predicate. -pub trait LogMatcher: Send + Sync { - /// Return true when the log should be routed to the handler. - fn matches(&self, log: &Log) -> bool; -} - -/// Route-key extraction strategy for logs. -#[derive(Clone)] -pub enum RouteKeySpec { - /// Route by emitting address. - EmitterAddress, - /// Route by indexed topic. - Topic { - /// Topic index. - index: usize, - }, - /// Route by a byte slice in log data. - DataSlice { - /// Byte offset in the data payload. - offset: usize, - /// Number of bytes to copy. - len: usize, - }, - /// Custom extractor. - Custom(Arc), +fn enrich_block_ref(retained: &mut BlockRef, incoming: &BlockRef) { + if retained.parent_hash.is_none() { + retained.parent_hash = incoming.parent_hash; + } + if retained.timestamp.is_none() { + retained.timestamp = incoming.timestamp; + } } -impl RouteKeySpec { - /// Extract a route key from a log. - pub fn extract(&self, log: &Log) -> Option { - match self { - Self::EmitterAddress => Some(RouteKey::Address(log.address())), - Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32), - Self::DataSlice { offset, len } => { - let data = log.inner.data.data.as_ref(); - let end = offset.checked_add(*len)?; - data.get(*offset..end) - .map(|bytes| RouteKey::Bytes(bytes.to_vec())) +fn merged_chain_status(retained: &ChainStatus, incoming: &ChainStatus) -> ChainStatus { + let merged_block = |left: &BlockRef, right: &BlockRef| { + let mut block = *left; + enrich_block_ref(&mut block, right); + block + }; + match (retained, incoming) { + (ChainStatus::Pending, ChainStatus::Pending) => ChainStatus::Pending, + ( + ChainStatus::Preconfirmed { flashblock: left }, + ChainStatus::Preconfirmed { flashblock: right }, + ) => { + debug_assert_eq!(left, right, "compatible pre-confirmed records agree"); + ChainStatus::Preconfirmed { + flashblock: left.clone(), + } + } + ( + ChainStatus::Reorged { dropped_from: left }, + ChainStatus::Reorged { + dropped_from: right, + }, + ) => ChainStatus::Reorged { + dropped_from: merged_block(left, right), + }, + (left, right) => { + let (left_block, left_rank, left_confirmations) = canonical_status_parts(left) + .expect("compatible duplicate has a canonical lifecycle"); + let (right_block, right_rank, right_confirmations) = canonical_status_parts(right) + .expect("compatible duplicate has a canonical lifecycle"); + let block = merged_block(left_block, right_block); + let rank = left_rank.max(right_rank); + match rank { + 3 => ChainStatus::Finalized { block }, + 2 => ChainStatus::Safe { block }, + _ => ChainStatus::Included { + block, + confirmations: left_confirmations.max(right_confirmations), + }, } - Self::Custom(extractor) => extractor.extract(log), } } } -impl fmt::Debug for RouteKeySpec { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::EmitterAddress => f.write_str("EmitterAddress"), - Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(), - Self::DataSlice { offset, len } => f - .debug_struct("DataSlice") - .field("offset", offset) - .field("len", len) - .finish(), - Self::Custom(_) => f.write_str("Custom()"), +fn canonical_status_parts(status: &ChainStatus) -> Option<(&BlockRef, u8, u64)> { + match status { + ChainStatus::Included { + block, + confirmations, + } => Some((block, 1, *confirmations)), + ChainStatus::Safe { block } => Some((block, 2, 0)), + ChainStatus::Finalized { block } => Some((block, 3, 0)), + ChainStatus::Pending | ChainStatus::Preconfirmed { .. } | ChainStatus::Reorged { .. } => { + None } } } -/// Extracts custom route keys from logs. -pub trait RouteKeyExtractor: Send + Sync { - /// Extract a route key. - fn extract(&self, log: &Log) -> Option; +fn input_source_rank(source: InputSource) -> u8 { + match source { + InputSource::Backfill => 0, + InputSource::Poll => 1, + InputSource::Subscription => 2, + InputSource::Flashblocks => 3, + InputSource::Batch => 4, + InputSource::Synthetic => 5, + } } -/// Extracted route key. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum RouteKey { - /// Address key. - Address(Address), - /// 32-byte key. - Bytes32(B256), - /// Arbitrary bytes key. - Bytes(Vec), -} +/// Opaque subscriber-owned token attached to a delivered input batch. +/// +/// Subscribers that provide durable, at-least-once delivery can use this token +/// to identify the batch that becomes committable after runtime ingestion +/// succeeds. The runtime never interprets the bytes. A token must be immutable, +/// stable across replay, and must never identify two different batch payloads. +/// Subscriber implementations must preserve delivery order while one token is +/// awaiting acknowledgement; [`ReactiveEngine`] retries it before polling a +/// later batch. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct SubscriberDeliveryToken(Vec); + +impl SubscriberDeliveryToken { + /// Create an opaque delivery token from subscriber-owned bytes. + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + /// Borrow the opaque token bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } -/// Exact protocol-neutral key used to select candidate log handlers. -#[non_exhaustive] -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum LogRouteKey { - /// Emitting contract address. - Emitter(Address), - /// Exact indexed topic. - Topic { - /// Topic position in the log. - index: usize, - /// Expected topic value. - value: B256, - }, - /// Exact byte slice in the log data. - DataSlice { - /// Byte offset in the data payload. - offset: usize, - /// Expected bytes. - value: Vec, - }, + /// Consume the token into its opaque bytes. + pub fn into_bytes(self) -> Vec { + self.0 + } } -/// Non-empty exhaustive OR-set of exact log route keys. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct LogRouteIndex { - keys: Vec, -} +/// Opaque source checkpoint associated with a delivered batch. +/// +/// Unlike [`SubscriberDeliveryToken`], which identifies the delivery to +/// acknowledge, this value describes provider-specific resume state. The core +/// crate persists and returns the bytes without interpreting their format. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct SubscriberCheckpoint(Vec); -impl LogRouteIndex { - /// Construct an index from one required key and optional additional keys. - pub fn new(primary: LogRouteKey, additional: impl IntoIterator) -> Self { - let mut keys = vec![primary]; - for key in additional { - if !keys.contains(&key) { - keys.push(key); - } - } - Self { keys } +impl SubscriberCheckpoint { + /// Create an opaque source checkpoint from subscriber-owned bytes. + pub fn new(bytes: Vec) -> Self { + Self(bytes) } - /// Construct a single-key index. - pub fn single(key: LogRouteKey) -> Self { - Self { keys: vec![key] } + /// Borrow the opaque checkpoint bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.0 } - /// Exact keys in declaration order. - pub fn keys(&self) -> &[LogRouteKey] { - &self.keys + /// Consume the checkpoint into its opaque bytes. + pub fn into_bytes(self) -> Vec { + self.0 } } -/// Exact log route selected by [`ReactiveRegistry::route_log`]. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReactiveLogRoute { - /// Handler whose log interest matched. - pub handler_id: HandlerId, - /// Optional route key extracted from the matching log interest. - pub route_key: Option, +/// Subscriber-supplied commitment to the exact canonical wire payload of one +/// delivered batch. +/// +/// The core includes this value in its durable replay witness. It is required +/// for tokened block-header, full-block, and hydrated-transaction payloads whose +/// network-generic Rust response types cannot be serialized completely by the +/// core. The source must recompute the commitment from a stable canonical +/// encoding on every replay; reusing a commitment for changed bytes violates the +/// [`EventSubscriber`] contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct SubscriberPayloadCommitment(B256); + +impl SubscriberPayloadCommitment { + /// Wrap a cryptographic commitment produced by the subscriber. + pub const fn new(commitment: B256) -> Self { + Self(commitment) + } + + /// Return the committed digest. + pub const fn digest(&self) -> B256 { + self.0 + } } -/// Interest in block inputs. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct BlockInterest { - /// Block input mode. - pub mode: BlockInterestMode, +/// Durable subscriber position restored together with cache/runtime state. +/// +/// The core never interprets provider checkpoint bytes. Composite and remote +/// subscribers use this synchronous hand-off to seed their source cursors, +/// replay fences, and canonical overlap journals before polling resumes. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub struct SubscriberResumePosition { + /// Chain whose canonical position and provider cursor are being restored. + pub chain_id: u64, + /// Authoritative canonical coverage embodied by the restored cache. + pub coverage_head: BlockRef, + /// Ordered canonical identities still retained for in-window reconciliation. + pub canonical_history: Vec, + /// Last delivery token whose effects are already represented by the cache. + /// It may still be pending at the source when the process stopped after its + /// durable save but before the source acknowledgement committed. + pub delivery_token: Option, + /// Provider-specific durable cursor committed with that delivery. + pub subscriber_checkpoint: Option, } -impl Default for BlockInterest { - fn default() -> Self { +impl SubscriberResumePosition { + /// Construct a complete restored subscriber position. + pub fn new( + chain_id: u64, + coverage_head: BlockRef, + canonical_history: Vec, + delivery_token: Option, + subscriber_checkpoint: Option, + ) -> Self { Self { - mode: BlockInterestMode::Header, + chain_id, + coverage_head, + canonical_history, + delivery_token, + subscriber_checkpoint, } } } -/// Block subscription mode. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum BlockInterestMode { - /// Header-only block input. - Header, - /// Full block input. - FullBlock, +/// Runtime routing audience for one delivered subscriber batch. +/// +/// Historical catch-up for a newly registered handler must not be routed +/// through older handlers whose filters happen to overlap. Subscribers retain +/// that provenance by targeting the batch at the exact logical owners that +/// requested it. Ordinary canonical delivery remains broadcast to every +/// matching handler. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub enum DeliveryAudience { + /// Route each record through every matching registered handler. + #[default] + All, + /// Route each record only through the named matching handlers. + Owners(Vec), + /// Route through every matching handler except the named owners. + /// + /// Composite subscribers use this to deliver the residual audience after an + /// overlapping source already committed the same input for selected owners. + AllExcept(Vec), } -/// Interest in pending transaction inputs. -#[derive(Clone)] -pub struct PendingTxInterest { - /// Whether the handler requires full transaction bodies. - pub full_transactions: bool, - /// Sender matcher. - pub from: AddressMatcher, - /// Recipient matcher. - pub to: AddressMatcher, - /// Calldata selector matcher. - pub selectors: SelectorMatcher, - /// Optional local transaction matcher. - pub local_matcher: Option>>, +/// How one delivered record participates in the runtime's canonical state machine. +/// +/// Routing and chain authority are deliberately independent: [`DeliveryAudience`] +/// selects handlers, while this value decides whether a record may advance or +/// rewind global chain state. Historical replay for a newly added owner must use +/// [`OwnerCatchup`](Self::OwnerCatchup), even though its original on-chain status +/// is canonical. +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, +)] +#[non_exhaustive] +pub enum DeliveryScope { + /// Authoritative live canonical delivery. + #[default] + Canonical, + /// Authoritative historical/recovery delivery that advances canonical progress. + CanonicalProgress, + /// Historical replay routed to selected owners without changing global chain state. + OwnerCatchup, + /// Ephemeral pre-confirmation delivery applied only to the speculative + /// cache overlay. + Preconfirmed, } -impl Default for PendingTxInterest { - fn default() -> Self { +impl DeliveryScope { + const fn advances_canonical_state(self) -> bool { + matches!(self, Self::Canonical | Self::CanonicalProgress) + } +} + +/// One input together with its routing and canonical-processing provenance. +#[derive(Clone, Debug)] +pub struct ReactiveInputDelivery { + record: ReactiveInputRecord, + audience: DeliveryAudience, + scope: DeliveryScope, +} + +impl ReactiveInputDelivery { + /// Construct one lossless delivered record. + pub fn new( + record: ReactiveInputRecord, + audience: DeliveryAudience, + scope: DeliveryScope, + ) -> Self { Self { - full_transactions: false, - from: AddressMatcher::Any, - to: AddressMatcher::Any, - selectors: SelectorMatcher::Any, - local_matcher: None, + record, + audience, + scope, } } -} -impl PendingTxInterest { - fn matches_hash_only(&self) -> bool { - !self.full_transactions - && self.from.is_any() - && self.to.is_any() - && self.selectors.is_any() - && self.local_matcher.is_none() + /// Borrow the runtime input record. + pub const fn record(&self) -> &ReactiveInputRecord { + &self.record } - fn matches_tx(&self, tx: &N::TransactionResponse) -> bool { - self.from.matches(tx.from()) - && self.to.matches_option(tx.to()) - && self.selectors.matches(tx.input()) - && self - .local_matcher - .as_ref() - .is_none_or(|matcher| matcher.matches(tx)) + /// Borrow the exact routing audience. + pub const fn audience(&self) -> &DeliveryAudience { + &self.audience + } + + /// Return the record's canonical-processing scope. + pub const fn scope(&self) -> DeliveryScope { + self.scope + } + + /// Consume this value into its complete parts. + pub fn into_parts(self) -> (ReactiveInputRecord, DeliveryAudience, DeliveryScope) { + (self.record, self.audience, self.scope) } } -impl fmt::Debug for PendingTxInterest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("PendingTxInterest") - .field("full_transactions", &self.full_transactions) - .field("from", &self.from) - .field("to", &self.to) - .field("selectors", &self.selectors) - .field( - "local_matcher", - &self.local_matcher.as_ref().map(|_| ""), - ) - .finish() - } +/// Complete contents of a consumed [`ReactiveInputBatch`]. +/// +/// Use this instead of [`ReactiveInputBatch::into_records`], which intentionally +/// discards subscriber commit and chain-lifecycle metadata. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub struct ReactiveInputBatchParts { + /// Authoritative chain identity for controls and records in this batch. + pub chain_id: Option, + /// Records with per-record routing and chain provenance. + pub deliveries: Vec>, + /// Subscriber delivery token committed after ingestion. + pub delivery_token: Option, + /// Provider-specific resume cursor associated with the delivery. + pub subscriber_checkpoint: Option, + /// Exact opaque wire-payload commitment supplied by the subscriber. + pub payload_commitment: Option, + /// Ordered chain controls sharing the delivery's commit boundary. + pub chain_controls: Vec, } -/// Address matching helper for pending transaction interests. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum AddressMatcher { - /// Match every address. - Any, - /// Match one address. - Exact(Address), - /// Match any address in the list. - AnyOf(Vec
), +/// Batch of reactive input records. +#[derive(Clone, Debug)] +pub struct ReactiveInputBatch { + records: Vec>, + chain_id: Option, + delivery_token: Option, + subscriber_checkpoint: Option, + payload_commitment: Option, + audience: DeliveryAudience, + record_audiences: Option>, + delivery_scope: DeliveryScope, + record_delivery_scopes: Option>, + chain_controls: Vec, } -impl AddressMatcher { - /// Return true when the matcher is unconstrained. - pub fn is_any(&self) -> bool { - matches!(self, Self::Any) +type RuntimeInputDelivery = (ReactiveInputRecord, DeliveryAudience, DeliveryScope); + +impl ReactiveInputBatch { + /// Create a batch from records. + pub fn new(records: Vec>) -> Self { + let chain_id = common_record_chain_id(&records); + Self { + records, + chain_id, + delivery_token: None, + subscriber_checkpoint: None, + payload_commitment: None, + audience: DeliveryAudience::All, + record_audiences: None, + delivery_scope: DeliveryScope::Canonical, + record_delivery_scopes: None, + chain_controls: Vec::new(), + } } - /// Match a present address. - pub fn matches(&self, address: Address) -> bool { - match self { - Self::Any => true, - Self::Exact(expected) => *expected == address, - Self::AnyOf(addresses) => addresses.contains(&address), + /// Bind the complete batch, including control-only progress/finality, to a + /// chain. Runtime ingestion rejects a different cache chain. + pub fn with_chain_id(mut self, chain_id: u64) -> Self { + self.chain_id = Some(chain_id); + self + } + + /// Authoritative batch chain identity, when supplied or unambiguously + /// derived from its records. + pub const fn chain_id(&self) -> Option { + self.chain_id + } + + /// Attach the subscriber-owned token committed after successful ingestion. + pub fn with_delivery_token(mut self, token: SubscriberDeliveryToken) -> Self { + self.delivery_token = Some(token); + self + } + + /// Borrow the subscriber-owned delivery token, when present. + pub fn delivery_token(&self) -> Option<&SubscriberDeliveryToken> { + self.delivery_token.as_ref() + } + + /// Attach provider-specific resume state included by this delivery. + pub fn with_subscriber_checkpoint(mut self, checkpoint: SubscriberCheckpoint) -> Self { + self.subscriber_checkpoint = Some(checkpoint); + self + } + + /// Borrow provider-specific resume state, when present. + pub fn subscriber_checkpoint(&self) -> Option<&SubscriberCheckpoint> { + self.subscriber_checkpoint.as_ref() + } + + /// Attach a commitment to the exact canonical wire payload represented by + /// this batch. + pub fn with_payload_commitment(mut self, commitment: SubscriberPayloadCommitment) -> Self { + self.payload_commitment = Some(commitment); + self + } + + /// Borrow the subscriber-supplied exact payload commitment, when present. + pub const fn payload_commitment(&self) -> Option<&SubscriberPayloadCommitment> { + self.payload_commitment.as_ref() + } + + /// Restrict runtime routing to exact logical interest owners. + pub fn with_audience(mut self, audience: DeliveryAudience) -> Self { + self.audience = audience; + self.record_audiences = None; + self + } + + /// Delivery audience captured by the subscriber. + pub const fn audience(&self) -> &DeliveryAudience { + &self.audience + } + + /// Create a batch whose records retain independent delivery audiences. + pub fn from_scoped_records( + records: impl IntoIterator, DeliveryAudience)>, + ) -> Self { + let (records, record_audiences): (Vec<_>, Vec<_>) = records.into_iter().unzip(); + let chain_id = common_record_chain_id(&records); + Self { + records, + chain_id, + delivery_token: None, + subscriber_checkpoint: None, + payload_commitment: None, + audience: DeliveryAudience::All, + record_audiences: Some(record_audiences), + delivery_scope: DeliveryScope::Canonical, + record_delivery_scopes: None, + chain_controls: Vec::new(), } } - /// Match an optional address. - pub fn matches_option(&self, address: Option
) -> bool { - match (self, address) { - (Self::Any, _) => true, - (_, Some(address)) => self.matches(address), - _ => false, + /// Create a batch with independent routing and canonical provenance per record. + pub fn from_deliveries(deliveries: impl IntoIterator>) -> Self { + Self::from_scoped_records_with_delivery_scope( + deliveries + .into_iter() + .map(ReactiveInputDelivery::into_parts), + ) + } + + /// Audience for the record at `index`. + pub fn record_audience(&self, index: usize) -> Option<&DeliveryAudience> { + if index >= self.records.len() { + return None; } + Some( + self.record_audiences + .as_ref() + .and_then(|audiences| audiences.get(index)) + .unwrap_or(&self.audience), + ) } -} -/// Calldata selector matching helper. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum SelectorMatcher { - /// Match every selector. - Any, - /// Match any selector in the list. - AnyOf(Vec<[u8; 4]>), -} + /// Set how every record in this batch participates in canonical state. + pub fn with_delivery_scope(mut self, scope: DeliveryScope) -> Self { + self.delivery_scope = scope; + self.record_delivery_scopes = None; + self + } -impl SelectorMatcher { - /// Return true when the matcher is unconstrained. - pub fn is_any(&self) -> bool { - matches!(self, Self::Any) + /// Canonical-processing scope for the record at `index`. + pub fn record_delivery_scope(&self, index: usize) -> Option { + if index >= self.records.len() { + return None; + } + Some( + self.record_delivery_scopes + .as_ref() + .and_then(|scopes| scopes.get(index)) + .copied() + .unwrap_or(self.delivery_scope), + ) } - /// Match calldata bytes. - pub fn matches(&self, input: &Bytes) -> bool { - match self { - Self::Any => true, - Self::AnyOf(selectors) => input - .get(..4) - .and_then(|bytes| bytes.try_into().ok()) - .is_some_and(|selector| selectors.contains(&selector)), + fn from_scoped_records_with_delivery_scope( + records: impl IntoIterator, DeliveryAudience, DeliveryScope)>, + ) -> Self { + let mut input_records = Vec::new(); + let mut audiences = Vec::new(); + let mut scopes = Vec::new(); + for (record, audience, scope) in records { + input_records.push(record); + audiences.push(audience); + scopes.push(scope); + } + let chain_id = common_record_chain_id(&input_records); + Self { + records: input_records, + chain_id, + delivery_token: None, + subscriber_checkpoint: None, + payload_commitment: None, + audience: DeliveryAudience::All, + record_audiences: Some(audiences), + delivery_scope: DeliveryScope::Canonical, + record_delivery_scopes: Some(scopes), + chain_controls: Vec::new(), } } -} -/// Local predicate over a full pending transaction. -pub trait PendingTxMatcher: Send + Sync { - /// Return true when the transaction should be routed to the handler. - fn matches(&self, tx: &N::TransactionResponse) -> bool; -} + /// Attach ordered chain-lifecycle controls to this delivery. + /// + /// A control-only batch must also call [`with_chain_id`](Self::with_chain_id). + /// When records are present, their unanimous chain id is derived by the + /// constructor; a missing or cache-mismatched authoritative batch identity + /// is rejected before any control mutates runtime state. + pub fn with_chain_controls(mut self, controls: impl IntoIterator) -> Self { + self.chain_controls = controls.into_iter().collect(); + self + } -/// How a tracked account is kept live by the per-block root gate (Phase-8 step 4). -/// -/// The `storageHash` root gate behaves *oppositely* for two contract shapes, so -/// liveness strategy is per-contract: -/// -/// - A sparse-interest contract (a few balance slots, e.g. WETH) has its root -/// churn on nearly every block, so the root is a noisy gate — [`Slots`] opts -/// out. Its enumerated slots stay fresh via decoders + cadence reconcile. -/// - A whole-economic-state contract (e.g. a Uniswap-V2 pool) has -/// `root_moved ≈ my_state_changed`, so [`WholeAccount`] opts in: probe the root -/// each canonical block; a move a decoder did not cover is a coverage gap. -/// -/// A false-positive resync is never *incorrect* — it costs one batched read — so -/// the policy is a **pure cost knob**, not a correctness lever. -/// -/// [`Slots`]: TrackingPolicy::Slots -/// [`WholeAccount`]: TrackingPolicy::WholeAccount -#[derive(Clone, Debug)] -#[non_exhaustive] -pub enum TrackingPolicy { - /// Sparse interest (e.g. WETH: a few balance slots). The root churns on - /// nearly every block, so it is a noisy gate — this policy is **never** - /// root-gated (spec Decision 3). Keep the enumerated slots fresh via decoders - /// and cadence reconcile. - Slots { - /// The enumerated storage slots of interest. - slots: Vec, - }, - /// Whole economic state (e.g. a V2 pool). `root_moved ≈ my_state_changed`, so - /// the root is a tight, cheap gate: probe each canonical block; on a move no - /// decoder covered, emit a [`ReactiveReport::CoverageGap`] and schedule a - /// [`ResyncReason::RootMoved`] repair. - WholeAccount, - /// Balance / nonce / code-hash only — resolved from the same `get_proof` - /// response's account fields; no storage interest. Native balance/nonce - /// changes do **not** move the storage root, so this policy compares the - /// account fields directly across blocks rather than root-gating. - Scalars, -} + /// Ordered chain-lifecycle controls in this delivery. + pub fn chain_controls(&self) -> &[ChainControl] { + &self.chain_controls + } -/// How often the reactive root gate probes tracked accounts -/// ([`TrackingPolicy::WholeAccount`] / [`TrackingPolicy::Scalars`]; the -/// `Scalars` account-fields comparison rides the same firing). -/// -/// `eth_getProof` is the slowest read this crate issues, so per-block probing -/// is never the default. Skipping blocks is safe by construction: the gate -/// diffs `root_now` against its **persisted baseline**, never -/// block-over-block, so a move in any skipped block is still visible at the -/// next firing — cadence trades detection lag (at most `n − 1` blocks) for -/// cost, never eventual detection. The decoder-touched set accumulates across -/// skipped blocks and drains per firing, so a covered write in a skipped -/// block never false-positives as a [`ReactiveReport::CoverageGap`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RootGateCadence { - /// Probe at most once every `n` canonical blocks (the first canonical - /// block ever seen always fires, so baseline adoption does not wait a - /// full window). `EveryNBlocks(1)` is per-block probing. - EveryNBlocks(NonZeroU64), - /// Root gate off: coverage gaps surface only via decoders + freshness. - Disabled, -} + /// Borrow the records in this batch. + pub fn records(&self) -> &[ReactiveInputRecord] { + &self.records + } -impl RootGateCadence { - /// Probe at most once every `n` canonical blocks, clamping `0` to `1`. - pub fn every_n_blocks(n: u64) -> Self { - Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1")) + /// Consume the batch into only its input records. + /// + /// This is intentionally lossy: it discards the authoritative batch chain + /// identity, routing audiences, delivery scopes, ordered chain controls, + /// acknowledgement tokens, and provider checkpoints. Adapters should use + /// [`into_parts`](Self::into_parts) instead. + pub fn into_records(self) -> Vec> { + self.records } -} -impl Default for RootGateCadence { - /// Every 16 canonical blocks — ~3.2 min worst-case detection lag on - /// mainnet for a 16× probe-cost cut. Fast-block chains should *raise* - /// `n`, not lower it. - fn default() -> Self { - Self::every_n_blocks(16) + /// Consume the batch without losing subscriber or chain-lifecycle metadata. + pub fn into_parts(self) -> ReactiveInputBatchParts { + let chain_id = self.chain_id; + let delivery_token = self.delivery_token; + let subscriber_checkpoint = self.subscriber_checkpoint; + let payload_commitment = self.payload_commitment; + let chain_controls = self.chain_controls; + let audiences = self + .record_audiences + .unwrap_or_else(|| vec![self.audience; self.records.len()]); + let scopes = self + .record_delivery_scopes + .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]); + let deliveries = self + .records + .into_iter() + .zip(audiences) + .zip(scopes) + .map(|((record, audience), scope)| ReactiveInputDelivery::new(record, audience, scope)) + .collect(); + ReactiveInputBatchParts { + chain_id, + deliveries, + delivery_token, + subscriber_checkpoint, + payload_commitment, + chain_controls, + } } -} -/// Per-account baseline held by the root gate: the last observed on-chain root -/// and account fields, plus the block they were observed at. -/// -/// The gate diffs the on-chain root **across time** (never local-vs-chain, per -/// spec §6): it persists the *observed* root as a baseline and compares -/// `root_now` to it. This is a currency gate, not a completeness gate. -#[derive(Clone, Debug)] -struct TrackedRoot { - last_root: B256, - last_block: u64, - balance: U256, - nonce: u64, - code_hash: B256, + fn into_runtime_parts(self) -> (Vec>, Vec, Option) { + let audiences = self + .record_audiences + .unwrap_or_else(|| vec![self.audience; self.records.len()]); + let scopes = self + .record_delivery_scopes + .unwrap_or_else(|| vec![self.delivery_scope; self.records.len()]); + let records = self + .records + .into_iter() + .zip(audiences) + .zip(scopes) + .map(|((record, audience), scope)| (record, audience, scope)) + .collect(); + (records, self.chain_controls, self.chain_id) + } + + fn take_delivery_token(&mut self) -> Option { + self.delivery_token.take() + } + + fn take_subscriber_checkpoint(&mut self) -> Option { + self.subscriber_checkpoint.take() + } } -/// Request for authoritative state repair. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ResyncRequest { - /// Resync id. - pub id: ResyncId, - /// Reason for the request. - pub reason: ResyncReason, - /// Block selection for the read. - pub block: ResyncBlock, - /// Targets to resync. - pub targets: Vec, - /// Scheduling priority. - pub priority: ResyncPriority, +fn common_record_chain_id(records: &[ReactiveInputRecord]) -> Option { + let chain_id = records.first()?.context.chain_id?; + records + .iter() + .all(|record| record.context.chain_id == Some(chain_id)) + .then_some(chain_id) } -/// Resync id. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct ResyncId(String); +/// Pure synchronous handler for reactive inputs. +pub trait ReactiveHandler: Send + Sync { + /// Stable handler id. + fn id(&self) -> HandlerId; -impl ResyncId { - /// Create a resync id. - pub fn new(id: impl Into) -> Self { - Self(id.into()) + /// Interests used by subscribers and the local router. + fn interests(&self) -> Vec>; + + /// Exhaustive exact keys for log inputs this handler can accept. + /// + /// Returning `None` keeps the handler on the compatibility fallback path. + /// Returning an index promises that every matching log has at least one of + /// its keys; the registry still re-checks the handler's original + /// [`LogInterest`]s and local matchers before dispatch. + fn log_route_index(&self) -> Option { + None } + + /// Handle one input against a read-only cache view. + fn handle( + &self, + ctx: &ReactiveContext, + input: &ReactiveInput, + state: &dyn StateView, + ) -> Result; } -/// Reason for a resync request. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum ResyncReason { - /// Handler requested repair. - HandlerRequested, - /// State effect could not be applied completely. - SkippedStateEffect, - /// A missed block range was detected; caller-scheduled repair. - /// - /// The runtime does not fabricate a targetless [`ResyncRequest`] for a missed - /// range (there are no known targets to resync). This reason is provided so a - /// caller building its own repair in response to a - /// [`ReactiveReport::MissedBlockRange`] can attribute it. - MissedBlockRange, - /// A tracked account's storage root moved with no covering decoder. - /// - /// Emitted by the per-block root gate (Phase-8 step 4). A - /// [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked account's - /// `storageHash` moved between the adopted baseline and the current canonical - /// block, yet no decoder wrote that account during the block — a coverage gap. - /// The gate schedules a resync with this reason to re-read the account - /// authoritatively and self-heal the blind spot. Also used for the - /// [`Scalars`](TrackingPolicy::Scalars) account-field freshness path. - RootMoved, - /// Caller-defined reason. - Custom(String), -} - -/// Block target for a resync. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum ResyncBlock { - /// Latest block. - Latest, - /// Safe head. - Safe, - /// Finalized head. - Finalized, - /// Block number. - Number(u64), - /// Block hash and number. - Hash { - /// Block number. - number: u64, - /// Block hash. - hash: B256, - /// Require the hash to still be canonical. - require_canonical: bool, - }, +/// Hook invoked after reports are built and cache mutation phases have ended. +/// +/// Hooks are synchronous in-process observers, not a durable transactional +/// outbox. The runtime never dispatches reports for a batch it rejects or rolls +/// back during checkpoint staging, and it dispatches a successfully staged +/// batch at most once per live engine. A process crash can still occur between +/// hook dispatch and durable checkpoint or transport acknowledgement. External +/// side effects therefore need their own idempotency key (normally an +/// [`InputRef`] or [`SubscriberDeliveryToken`]) and durable delivery mechanism. +pub trait ReactiveHook: Send + Sync { + /// Observe a runtime report. + fn on_report(&self, report: Arc>); } -/// State target for a resync. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum ResyncTarget { - /// One storage slot. - StorageSlot { - /// Contract address. - address: Address, - /// Storage slot. - slot: U256, - }, - /// Multiple storage slots on one contract. - StorageSlots { - /// Contract address. - address: Address, - /// Storage slots. - slots: Vec, - }, - /// Account fields. - Account { - /// Account address. - address: Address, - /// Fields to resync. - fields: AccountFieldMask, - }, +/// Reactive subscription interest. +#[allow(clippy::large_enum_variant)] +#[derive(Clone)] +pub enum ReactiveInterest { + /// Log interest. + Logs(LogInterest), + /// Block interest. + Blocks(BlockInterest), + /// Pending transaction interest. + PendingTransactions(PendingTxInterest), } -/// Account fields requested by a resync. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub struct AccountFieldMask { - /// Balance field. - pub balance: bool, - /// Nonce field. - pub nonce: bool, - /// Code field. - pub code: bool, +impl fmt::Debug for ReactiveInterest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Logs(interest) => f.debug_tuple("Logs").field(interest).finish(), + Self::Blocks(interest) => f.debug_tuple("Blocks").field(interest).finish(), + Self::PendingTransactions(interest) => f + .debug_tuple("PendingTransactions") + .field(interest) + .finish(), + } + } } -/// Resync priority. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub enum ResyncPriority { - /// Low priority. - Low, - /// Normal priority. - #[default] - Normal, - /// High priority. - High, +/// Interest in logs. +#[derive(Clone)] +pub struct LogInterest { + /// Provider-side filter. + pub provider_filter: Filter, + /// Optional local matcher for predicates providers cannot express. + pub local_matcher: Option>, + /// Optional route-key extraction strategy. + pub route_key: Option, } -/// Rich invalidation request lowered to [`StateUpdate::Purge`]. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct InvalidationRequest { - /// Purge scope. - pub scope: PurgeScope, - /// Address to purge. - pub address: Address, - /// Reason for reporting. - pub reason: InvalidationReason, -} +impl LogInterest { + /// Return true if the log matches both the provider filter and local matcher. + pub fn matches(&self, log: &Log) -> bool { + self.provider_filter.rpc_matches(log) + && self + .local_matcher + .as_ref() + .is_none_or(|matcher| matcher.matches(log)) + } -/// Invalidation reason. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum InvalidationReason { - /// Handler requested invalidation. - HandlerRequested, - /// Reorg invalidation. - Reorg, - /// Caller-defined reason. - Custom(String), + /// Extract the route key for a matching log, if configured. + pub fn route_key(&self, log: &Log) -> Option { + self.route_key.as_ref().and_then(|spec| spec.extract(log)) + } } -/// Speculative signal emitted by handlers. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SpeculativeRequest { - /// Speculative request id. - pub id: SpeculativeId, - /// Input that triggered the request. - pub input_ref: InputRef, - /// Labels for downstream routing. - pub labels: Vec, +impl fmt::Debug for LogInterest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LogInterest") + .field("provider_filter", &self.provider_filter) + .field( + "local_matcher", + &self.local_matcher.as_ref().map(|_| ""), + ) + .field("route_key", &self.route_key) + .finish() + } } -/// Speculative request id. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct SpeculativeId(String); - -impl SpeculativeId { - /// Create a speculative id. - pub fn new(id: impl Into) -> Self { - Self(id.into()) - } +/// Local log predicate. +pub trait LogMatcher: Send + Sync { + /// Return true when the log should be routed to the handler. + fn matches(&self, log: &Log) -> bool; } -/// Configuration for [`ReactiveRuntime`]. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ReactiveConfig { - /// Hook backpressure policy. **Reserved — currently has no effect.** Hook - /// dispatch is synchronous today (every report is delivered to every hook in - /// order), so this field is a no-op placeholder for a future async dispatcher. - /// Setting it to anything other than the default does not change behavior. - pub hook_backpressure: HookBackpressure, - /// Reorg journal depth: the number of recent canonical blocks whose effects - /// are journaled for rollback. This is **load-bearing** for reorg recovery: - /// only blocks still resident in the journal can be recovered. A reorg deeper - /// than `journal_depth` recovers the blocks still in the journal and leaves - /// the aged-out blocks' effects in place — they are **neither rolled back nor - /// purged**, so the freshness/validation loop is the only backstop for that - /// span. `0` disables journaling entirely: no reorg is rolled back or purged. - /// - /// Set `journal_depth` to exceed the deepest reorg you intend to recover - /// precisely. When a reorg references a block that is no longer in the journal, - /// the runtime emits a `tracing::warn!` so the under-recovery is observable - /// rather than silent. - pub journal_depth: usize, +/// Route-key extraction strategy for logs. +#[derive(Clone)] +pub enum RouteKeySpec { + /// Route by emitting address. + EmitterAddress, + /// Route by indexed topic. + Topic { + /// Topic index. + index: usize, + }, + /// Route by a byte slice in log data. + DataSlice { + /// Byte offset in the data payload. + offset: usize, + /// Number of bytes to copy. + len: usize, + }, + /// Custom extractor. + Custom(Arc), } -impl Default for ReactiveConfig { - fn default() -> Self { - Self { - hook_backpressure: HookBackpressure::Block, - journal_depth: 64, +impl RouteKeySpec { + /// Extract a route key from a log. + pub fn extract(&self, log: &Log) -> Option { + match self { + Self::EmitterAddress => Some(RouteKey::Address(log.address())), + Self::Topic { index } => log.topics().get(*index).copied().map(RouteKey::Bytes32), + Self::DataSlice { offset, len } => { + let data = log.inner.data.data.as_ref(); + let end = offset.checked_add(*len)?; + data.get(*offset..end) + .map(|bytes| RouteKey::Bytes(bytes.to_vec())) + } + Self::Custom(extractor) => extractor.extract(log), } } } -/// Hook backpressure policy. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum HookBackpressure { - /// Block the producer until hooks are accepted. - Block, - /// Drop the newest report under pressure. - DropNewest, - /// Drop the oldest report under pressure. - DropOldest, - /// Return an error under pressure. - Error, +impl fmt::Debug for RouteKeySpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmitterAddress => f.write_str("EmitterAddress"), + Self::Topic { index } => f.debug_struct("Topic").field("index", index).finish(), + Self::DataSlice { offset, len } => f + .debug_struct("DataSlice") + .field("offset", offset) + .field("len", len) + .finish(), + Self::Custom(_) => f.write_str("Custom()"), + } + } } -/// Queryable coarse health of the reactive cache. -/// -/// The runtime starts [`Healthy`](CacheHealth::Healthy) and transitions to a -/// degraded or unhealthy state when it detects that its recovery guarantees no -/// longer hold (for example a reorg that runs deeper than the journal, so some -/// dropped effects are neither rolled back nor purged). Later waves report -/// missed-range and coverage-gap conditions into the same state machine. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -#[non_exhaustive] -pub enum CacheHealth { - /// All recovery guarantees hold; the cache is fully self-consistent. - #[default] - Healthy, - /// A recoverable inconsistency was detected (for example under-recovered - /// reorg effects); `since_block` records the block that triggered the - /// transition. - Degraded { - /// Block number at which the degradation was first observed. - since_block: u64, - }, - /// A more serious inconsistency was detected; `since_block` records the - /// block that triggered the transition. - Unhealthy { - /// Block number at which the unhealthy condition was first observed. - since_block: u64, - }, +/// Extracts custom route keys from logs. +pub trait RouteKeyExtractor: Send + Sync { + /// Extract a route key. + fn extract(&self, log: &Log) -> Option; } -/// Point-in-time copy of the reactive runtime's observability counters. -/// -/// Returned by [`ReactiveRuntime::metrics`]. Each field is a monotonically -/// increasing count over the lifetime of the runtime. Counters wired by later -/// waves (missed-range detection, storage-hash coverage gaps, stale-verdict -/// tracking) remain zero until those waves land. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +/// Extracted route key. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum RouteKey { + /// Address key. + Address(Address), + /// 32-byte key. + Bytes32(B256), + /// Arbitrary bytes key. + Bytes(Vec), +} + +/// Exact protocol-neutral key used to select candidate log handlers. #[non_exhaustive] -pub struct CacheMetricsSnapshot { - /// Reorgs that ran deeper than the journal, so aged-out effects could not be - /// rolled back or purged. - pub deep_reorgs: u64, - /// Reorgs for which a [`ReorgReport`] recovery ran (including deep reorgs). - pub reorgs_recovered: u64, - /// Storage resync targets considered by the resync execution pass. - pub resync_requests: u64, - /// Storage resync targets that could not be fetched or applied. - pub resync_failures: u64, - /// Ranges of blocks the runtime detected it did not observe (reserved). - pub missed_ranges: u64, - /// Storage-hash coverage gaps detected (reserved). - pub coverage_gaps: u64, - /// Pending-source inputs that attempted a canonical cache effect. - pub pending_contamination: u64, - /// Verdicts served past their freshness horizon (reserved). - pub stale_verdicts: u64, +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum LogRouteKey { + /// Emitting contract address. + Emitter(Address), + /// Exact indexed topic. + Topic { + /// Topic position in the log. + index: usize, + /// Expected topic value. + value: B256, + }, + /// Exact byte slice in the log data. + DataSlice { + /// Byte offset in the data payload. + offset: usize, + /// Expected bytes. + value: Vec, + }, } -/// Internal atomic-backed counters mirrored by [`CacheMetricsSnapshot`]. -/// -/// Fields are [`AtomicU64`] so counters can be incremented behind a shared -/// reference; [`ReactiveRuntime::metrics`] loads each with [`Ordering::Relaxed`] -/// into a plain [`CacheMetricsSnapshot`]. -#[derive(Debug, Default)] -struct CacheMetrics { - deep_reorgs: AtomicU64, - reorgs_recovered: AtomicU64, - resync_requests: AtomicU64, - resync_failures: AtomicU64, - missed_ranges: AtomicU64, - coverage_gaps: AtomicU64, - pending_contamination: AtomicU64, - stale_verdicts: AtomicU64, +/// Non-empty exhaustive OR-set of exact log route keys. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogRouteIndex { + keys: Vec, } -impl CacheMetrics { - fn snapshot(&self) -> CacheMetricsSnapshot { - CacheMetricsSnapshot { - deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed), - reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed), - resync_requests: self.resync_requests.load(Ordering::Relaxed), - resync_failures: self.resync_failures.load(Ordering::Relaxed), - missed_ranges: self.missed_ranges.load(Ordering::Relaxed), - coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed), - pending_contamination: self.pending_contamination.load(Ordering::Relaxed), - stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed), +impl LogRouteIndex { + /// Construct an index from one required key and optional additional keys. + pub fn new(primary: LogRouteKey, additional: impl IntoIterator) -> Self { + let mut keys = vec![primary]; + for key in additional { + if !keys.contains(&key) { + keys.push(key); + } } + Self { keys } } -} -/// Runtime report. -#[derive(Clone, Debug)] -#[non_exhaustive] -pub enum ReactiveReport { - /// Input was accepted after deduplication. - Input(InputReport), - /// Handlers produced outcomes. - Decoded(DecodedReport), - /// Direct state effects were applied. - Applied(AppliedReport), - /// Resync request was scheduled or completed. - Resynced(ResyncReport), - /// Block-level processing completed. - BlockCommitted(BlockReport), - /// Reorg processing report. - Reorg(ReorgReport), - /// A forward gap in the canonical block sequence was detected: blocks between - /// the last-seen head and an arriving block were never observed. - MissedBlockRange(MissedRangeReport), - /// Cache health transitioned between states. - Health(HealthReport), - /// A tracked account's storage root moved with no covering decoder — a - /// coverage gap the per-block root gate detected (Phase-8 step 4). - CoverageGap(CoverageGapReport), - /// Runtime or handler error. - Error(ReactiveErrorReport), + /// Construct a single-key index. + pub fn single(key: LogRouteKey) -> Self { + Self { keys: vec![key] } + } + + /// Exact keys in declaration order. + pub fn keys(&self) -> &[LogRouteKey] { + &self.keys + } } -/// Input acceptance report. -#[derive(Clone, Debug)] -pub struct InputReport { - /// Input reference. - pub input_ref: InputRef, - /// Input context. - pub context: ReactiveContext, - /// Network marker. - pub _network: PhantomData, +/// Exact log route selected by [`ReactiveRegistry::route_log`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReactiveLogRoute { + /// Handler whose log interest matched. + pub handler_id: HandlerId, + /// Optional route key extracted from the matching log interest. + pub route_key: Option, } -/// Decoding report. -#[derive(Clone, Debug)] -pub struct DecodedReport { - /// Input reference. - pub input_ref: InputRef, - /// Handler ids that matched the input. - pub handler_ids: Vec, - /// Network marker. - pub _network: PhantomData, +/// Interest in block inputs. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct BlockInterest { + /// Block input mode. + pub mode: BlockInterestMode, } -/// Applied state report. -#[derive(Clone, Debug)] -pub struct AppliedReport { - /// Input reference. - pub input_ref: InputRef, - /// Handler that produced the applied effects. - pub handler_id: HandlerId, - /// State effect quality. - pub quality: StateEffectQuality, - /// Labels emitted by the handler. - pub tags: Vec, - /// Merged state diff from applied updates and invalidations. - pub diff: StateDiff, - /// State updates applied through the cache. - pub state_updates: Vec, - /// Invalidation requests lowered to purge updates. - pub invalidations: Vec, - /// Resync requests surfaced for a scheduler. - pub resyncs: Vec, - /// Speculative requests surfaced for downstream users. - pub speculative: Vec, - /// Hook signals emitted by the handler. - pub hook_signals: Vec, - /// Network marker. - pub _network: PhantomData, +impl Default for BlockInterest { + fn default() -> Self { + Self { + mode: BlockInterestMode::Header, + } + } } -/// Report of the storage resync requests executed during an ingest cycle: the -/// requests considered, the authoritative updates built from successful fetches -/// (and their applied diff), and any targets that could not be resynced. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ResyncReport { - /// Requests considered by the resync execution pass. - pub requested: Vec, - /// Authoritative state updates built from successful resync fetches. - pub state_updates: Vec, - /// Diff returned by applying [`state_updates`](Self::state_updates). - pub diff: StateDiff, - /// Targets that could not be resynced. - pub failed: Vec, +/// Block subscription mode. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum BlockInterestMode { + /// Header-only block input. + Header, + /// Full block input. + FullBlock, } -/// One resync target that could not be fetched or applied. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ResyncFailure { - /// Request that produced the failed target. - pub request_id: ResyncId, - /// Block selection used for the failed target. - pub block: ResyncBlock, - /// Target that could not be resynced. - pub target: ResyncTarget, - /// Stable failure classification for retry policy and metrics. - pub kind: ResyncFailureKind, - /// Human-readable failure reason. - pub message: String, -} - -/// Stable classification for a failed resync target. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum ResyncFailureKind { - /// A storage target could not be fetched because no storage batch fetcher is configured. - MissingStorageFetcher, - /// The storage batch fetcher returned an error for the requested slot. - StorageFetchFailed, - /// The storage batch fetcher did not return a result for the requested slot. - StorageFetchOmitted, - /// An account target could not be fetched because no account proof fetcher is configured. - MissingAccountFetcher, - /// The account proof fetcher returned an error for the requested address. - AccountFetchFailed, - /// The account proof fetcher did not return a result for the requested address. - AccountFetchOmitted, -} - -/// Block processing report. -#[derive(Clone, Debug)] -pub struct BlockReport { - /// Block reference, when known. - pub block: Option, - /// Input references committed for the block. - pub inputs: Vec, - /// Network marker. - pub _network: PhantomData, -} - -/// Report of a detected reorg and the recovery it performed: the dropped -/// block(s) and inputs, the exact rollback updates applied for reversible dropped -/// effects, the conservative purge updates for irreversible ones, the canceled -/// hash-pinned resyncs, and why recovery ran. -/// -/// Recovery only covers blocks still resident in the journal. If a reorg runs -/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not -/// appear here and their effects are neither rolled back nor purged (the runtime -/// logs a `tracing::warn!` in that case); the freshness/validation loop is the -/// backstop for that span. -#[derive(Clone, Debug)] -pub struct ReorgReport { - /// First dropped block, when known. - pub dropped: Option, - /// Blocks dropped from the journal, in ascending journal order. - pub dropped_blocks: Vec, - /// Input references that belonged to dropped blocks. - pub dropped_inputs: Vec, - /// Exact rollback updates applied for reversible dropped effects. - pub rollback_updates: Vec, - /// Diff returned by applying [`rollback_updates`](Self::rollback_updates). - pub rollback_diff: StateDiff, - /// Conservative purge updates applied for irreversible dropped effects. - pub purge_updates: Vec, - /// Diff returned by applying [`purge_updates`](Self::purge_updates). - pub purge_diff: StateDiff, - /// Hash-pinned pending resync requests canceled because their block was dropped. - pub canceled_resyncs: Vec, - /// Reorg trigger. - pub reason: ReorgReason, - /// Network marker. - pub _network: PhantomData, -} - -/// Reason reorg recovery ran. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum ReorgReason { - /// A provider emitted an Alloy removed log. - RemovedLog, - /// The input context explicitly marked an input as reorged. - ReorgedInput, - /// A canonical block did not connect to the journaled head. - ParentMismatch, -} - -/// Report of a forward gap in the canonical block sequence: an arriving block -/// whose number is more than one past the last-seen head, so the blocks in -/// between were never observed (for example during a subscription disconnect). -/// -/// The arriving block is still accepted and applied — the chain extends — so this -/// report only makes the skipped span observable; it does not drop the block. The -/// span `from..=to` is inclusive of both endpoints. -#[derive(Clone, Debug)] -pub struct MissedRangeReport { - /// First skipped block (`last-seen block number + 1`). - pub from: u64, - /// Last skipped block (`arriving block number - 1`). - pub to: u64, - /// The arriving block's number. - pub block: u64, - /// Network marker. - pub _network: PhantomData, -} - -/// Report of a [`CacheHealth`] transition, emitted into the ingest cycle that -/// caused it and delivered to hooks through the normal dispatch path. -#[derive(Clone, Debug)] -pub struct HealthReport { - /// Health state before the transition. - pub from: CacheHealth, - /// Health state after the transition. - pub to: CacheHealth, - /// Block number associated with the transition, when known. - pub block: Option, - /// Network marker. - pub _network: PhantomData, -} - -/// Report that a tracked account's storage root moved on a canonical block that -/// no decoder covered — a coverage gap surfaced by the per-block root gate -/// (Phase-8 step 4). -/// -/// An account's `storageHash` is a collision-resistant commitment over all of its -/// storage, so a moved root proves *something* under the account changed. When -/// that account is [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked and the -/// batch's touched-address set does not include it, the change arrived through a -/// path no decoder observed. The runtime emits this report (delivered through the -/// normal dispatch path so [`ReactiveHook::on_report`] observers see it), -/// increments [`CacheMetricsSnapshot::coverage_gaps`], and schedules a -/// [`ResyncReason::RootMoved`] repair to re-read the account authoritatively. -#[derive(Clone, Debug)] -pub struct CoverageGapReport { - /// The tracked account whose root moved with no covering decoder. - pub address: Address, - /// The canonical block number at which the gap was observed. - pub block: u64, - /// Network marker. - pub _network: PhantomData, -} - -/// Report of a non-fatal error surfaced during an ingest cycle, with the -/// associated input (when known) and a human-readable message. -#[derive(Clone, Debug)] -pub struct ReactiveErrorReport { - /// Input associated with the error, when known. - pub input_ref: Option, - /// Error message. - pub message: String, - /// Network marker. - pub _network: PhantomData, -} - -/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and -/// [`ReactiveRuntime::ingest_batch_with_resync`]. -#[derive(Clone, Debug)] -pub struct ReactiveBatchReport { - /// Applied reports in commit order. - pub applied: Vec>, - /// Resync requests surfaced during the batch. - pub resyncs: Vec, - /// Speculative requests surfaced during the batch. - pub speculative: Vec, - /// Hook reports dispatched after mutation phases. - pub reports: Vec>>, +/// Interest in pending transaction inputs. +#[derive(Clone)] +pub struct PendingTxInterest { + /// Whether the handler requires full transaction bodies. + pub full_transactions: bool, + /// Sender matcher. + pub from: AddressMatcher, + /// Recipient matcher. + pub to: AddressMatcher, + /// Calldata selector matcher. + pub selectors: SelectorMatcher, + /// Optional local transaction matcher. + pub local_matcher: Option>>, } -impl Default for ReactiveBatchReport { +impl Default for PendingTxInterest { fn default() -> Self { Self { - applied: Vec::new(), - resyncs: Vec::new(), - speculative: Vec::new(), - reports: Vec::new(), + full_transactions: false, + from: AddressMatcher::Any, + to: AddressMatcher::Any, + selectors: SelectorMatcher::Any, + local_matcher: None, } } } -/// Error returned by a handler. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct HandlerError { - message: String, -} +impl PendingTxInterest { + fn matches_hash_only(&self) -> bool { + !self.full_transactions + && self.from.is_any() + && self.to.is_any() + && self.selectors.is_any() + && self.local_matcher.is_none() + } -impl HandlerError { - /// Create a handler error from a message. - pub fn new(message: impl Into) -> Self { - Self { - message: message.into(), - } + fn matches_tx(&self, tx: &N::TransactionResponse) -> bool { + self.from.matches(tx.from()) + && self.to.matches_option(tx.to()) + && self.selectors.matches(tx.input()) + && self + .local_matcher + .as_ref() + .is_none_or(|matcher| matcher.matches(tx)) } } -impl fmt::Display for HandlerError { +impl fmt::Debug for PendingTxInterest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.message.fmt(f) + f.debug_struct("PendingTxInterest") + .field("full_transactions", &self.full_transactions) + .field("from", &self.from) + .field("to", &self.to) + .field("selectors", &self.selectors) + .field( + "local_matcher", + &self.local_matcher.as_ref().map(|_| ""), + ) + .finish() } } -impl std::error::Error for HandlerError {} - -impl From for HandlerError { - fn from(message: String) -> Self { - Self::new(message) - } +/// Address matching helper for pending transaction interests. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum AddressMatcher { + /// Match every address. + Any, + /// Match one address. + Exact(Address), + /// Match any address in the list. + AnyOf(Vec
), } -impl From<&str> for HandlerError { - fn from(message: &str) -> Self { - Self::new(message) +impl AddressMatcher { + /// Return true when the matcher is unconstrained. + pub fn is_any(&self) -> bool { + matches!(self, Self::Any) } -} -/// Runtime error. -#[derive(Debug, thiserror::Error)] -pub enum ReactiveError { - /// Handler returned an error. - #[error("handler `{handler_id}` failed: {source}")] - HandlerFailed { - /// Handler id. - handler_id: HandlerId, - /// Handler error. - source: HandlerError, - }, - /// Multiple handlers emitted incompatible absolute writes for one input. - #[error( - "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`" - )] - ConflictingEffects { - /// Input reference. - input_ref: Box, - /// Conflicting target. - target: Box, - /// First handler id. - first: HandlerId, - /// Second handler id. - second: HandlerId, - }, - /// Pending inputs attempted to mutate canonical cache state. - #[error( - "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`" - )] - InvalidPendingEffect { - /// Input reference. - input_ref: Box, - /// Handler id. - handler_id: HandlerId, - /// Effect kind. - effect_kind: &'static str, - }, - /// Registration error. - #[error(transparent)] - Register(#[from] RegisterError), + /// Match a present address. + pub fn matches(&self, address: Address) -> bool { + match self { + Self::Any => true, + Self::Exact(expected) => *expected == address, + Self::AnyOf(addresses) => addresses.contains(&address), + } + } + + /// Match an optional address. + pub fn matches_option(&self, address: Option
) -> bool { + match (self, address) { + (Self::Any, _) => true, + (_, Some(address)) => self.matches(address), + _ => false, + } + } } -/// Handler registration error. -#[derive(Debug, thiserror::Error)] -pub enum RegisterError { - /// Duplicate handler id. - #[error("handler id `{0}` is already registered")] - DuplicateHandler(HandlerId), +/// Calldata selector matching helper. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum SelectorMatcher { + /// Match every selector. + Any, + /// Match any selector in the list. + AnyOf(Vec<[u8; 4]>), } -/// Error returned when [`ReactiveEngine`] cannot register a handler on both the -/// runtime and subscriber sides. -#[derive(Debug, thiserror::Error)] -pub enum ReactiveEngineRegisterError { - /// Runtime registry rejected the handler. - #[error(transparent)] - Register(#[from] RegisterError), - /// Subscriber rejected the handler's interests. - #[error(transparent)] - Subscriber(#[from] SubscriberError), +impl SelectorMatcher { + /// Return true when the matcher is unconstrained. + pub fn is_any(&self) -> bool { + matches!(self, Self::Any) + } + + /// Match calldata bytes. + pub fn matches(&self, input: &Bytes) -> bool { + match self { + Self::Any => true, + Self::AnyOf(selectors) => input + .get(..4) + .and_then(|bytes| bytes.try_into().ok()) + .is_some_and(|selector| selectors.contains(&selector)), + } + } } -/// Error returned by [`ReactiveEngine`] helpers that combine subscriber polling -/// and runtime ingestion. -#[derive(Debug, thiserror::Error)] -pub enum ReactiveEngineError { - /// Subscriber polling failed. - #[error(transparent)] - Subscriber(#[from] SubscriberError), - /// Runtime ingestion failed. - #[error(transparent)] - Runtime(#[from] ReactiveError), +/// Local predicate over a full pending transaction. +pub trait PendingTxMatcher: Send + Sync { + /// Return true when the transaction should be routed to the handler. + fn matches(&self, tx: &N::TransactionResponse) -> bool; } -/// Absolute write target used for conflict reports. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum EffectTarget { - /// Storage slot target. - StorageSlot { - /// Contract address. - address: Address, - /// Storage slot. - slot: U256, - }, - /// Account balance target. - AccountBalance { - /// Account address. - address: Address, - }, - /// Account nonce target. - AccountNonce { - /// Account address. - address: Address, - }, - /// Account code target. - AccountCode { - /// Account address. - address: Address, - }, - /// Masked storage slot target. - MaskedStorageSlot { - /// Contract address. - address: Address, - /// Storage slot. - slot: U256, - /// Bit mask. - mask: U256, +/// How a tracked account is kept live by the per-block root gate (Phase-8 step 4). +/// +/// The `storageHash` root gate behaves *oppositely* for two contract shapes, so +/// liveness strategy is per-contract: +/// +/// - A sparse-interest contract (a few balance slots, e.g. WETH) has its root +/// churn on nearly every block, so the root is a noisy gate — [`Slots`] opts +/// out. Its enumerated slots stay fresh via decoders + cadence reconcile. +/// - A whole-economic-state contract (e.g. a Uniswap-V2 pool) has +/// `root_moved ≈ my_state_changed`, so [`WholeAccount`] opts in: probe the root +/// each canonical block; a move a decoder did not cover is a coverage gap. +/// +/// A false-positive resync is never *incorrect* — it costs one batched read — so +/// the policy is a **pure cost knob**, not a correctness lever. +/// +/// [`Slots`]: TrackingPolicy::Slots +/// [`WholeAccount`]: TrackingPolicy::WholeAccount +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub enum TrackingPolicy { + /// Sparse interest (e.g. WETH: a few balance slots). The root churns on + /// nearly every block, so it is a noisy gate — this policy is **never** + /// root-gated (spec Decision 3). Keep the enumerated slots fresh via decoders + /// and cadence reconcile. + Slots { + /// The enumerated storage slots of interest. + slots: Vec, }, + /// Whole economic state (e.g. a V2 pool). `root_moved ≈ my_state_changed`, so + /// the root is a tight, cheap gate: probe each canonical block; on a move no + /// decoder covered, emit a [`ReactiveReport::CoverageGap`] and schedule a + /// [`ResyncReason::RootMoved`] repair. + WholeAccount, + /// Balance / nonce / code-hash only — resolved from the same `get_proof` + /// response's account fields; no storage interest. Native balance/nonce + /// changes do **not** move the storage root, so this policy compares the + /// account fields directly across blocks rather than root-gating. + Scalars, } -#[derive(Clone, Debug, PartialEq, Eq)] -enum AbsoluteValue { - U256(U256), - U64(u64), - Bytes(Bytes), +/// How often the reactive root gate probes tracked accounts +/// ([`TrackingPolicy::WholeAccount`] / [`TrackingPolicy::Scalars`]; the +/// `Scalars` account-fields comparison rides the same firing). +/// +/// `eth_getProof` is the slowest read this crate issues, so per-block probing +/// is never the default. Skipping blocks is safe by construction: the gate +/// diffs `root_now` against its **persisted baseline**, never +/// block-over-block, so a move in any skipped block is still visible at the +/// next firing — cadence trades detection lag (at most `n − 1` blocks) for +/// cost, never eventual detection. The decoder-touched set accumulates across +/// skipped blocks and drains per firing, so a covered write in a skipped +/// block never false-positives as a [`ReactiveReport::CoverageGap`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum RootGateCadence { + /// Probe at most once every `n` canonical blocks (the first canonical + /// block ever seen always fires, so baseline adoption does not wait a + /// full window). `EveryNBlocks(1)` is per-block probing. + EveryNBlocks(NonZeroU64), + /// Root gate off: coverage gaps surface only via decoders + freshness. + Disabled, } -/// Reactive runtime. -pub struct ReactiveRuntime { - registry: ReactiveRegistry, - hooks: Vec>>, - config: ReactiveConfig, - journal: VecDeque>, - pending_resyncs: Vec, - health: CacheHealth, - metrics: CacheMetrics, - /// Opt-in freshness registry the runtime stamps for canonical event writes. - /// - /// `None` by default (behavior unchanged); populated by - /// [`enable_freshness_stamping`](Self::enable_freshness_stamping). When - /// present, applying a canonical handler storage-slot effect stamps the - /// touched `(address, slot)` as [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)` - /// so event-maintained slots stop being needlessly re-verified while aging to - /// volatile once the clock passes `N`. - freshness: Option, - /// Per-account tracking registry consulted by the per-block root gate - /// (Phase-8 step 4). Empty by default; populated by - /// [`track_account`](Self::track_account). When empty the gate is a no-op. - tracking: HashMap, - /// Per-account root/field baselines the gate diffs against across blocks. - /// Adopted on first probe and re-adopted on every observed move. - tracked_roots: HashMap, - /// How often the root gate fires (§6.2); see [`RootGateCadence`]. - root_gate_cadence: RootGateCadence, - /// Canonical block of the last root-gate firing. `None` until the first - /// firing (which happens at the first canonical block ever seen, so - /// baseline adoption never waits a full cadence window). - last_gate_block: Option, - /// Union of decoder-touched addresses since the last root-gate firing, - /// drained when it fires. Under cadence the gap rule "root moved ∧ addr ∉ - /// touched" must judge against every covered write in the window, or a - /// decoder-covered write in a skipped block would false-positive as a - /// [`ReactiveReport::CoverageGap`]. - touched_since_gate: HashSet
, +impl RootGateCadence { + /// Probe at most once every `n` canonical blocks, clamping `0` to `1`. + pub fn every_n_blocks(n: u64) -> Self { + Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1")) + } } -#[derive(Clone, Debug)] -struct BlockJournal { - block: BlockRef, - inputs: Vec, - applied: Vec>, - resynced: Vec, +impl Default for RootGateCadence { + /// Every 16 canonical blocks — ~3.2 min worst-case detection lag on + /// mainnet for a 16× probe-cost cut. Fast-block chains should *raise* + /// `n`, not lower it. + fn default() -> Self { + Self::every_n_blocks(16) + } } -/// Registry and router for provider-neutral reactive handlers. +/// Per-account baseline held by the root gate: the last observed on-chain root +/// and account fields, plus the block they were observed at. /// -/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes -/// consolidated provider-side log filters for subscription setup, and routes -/// provider logs back to the exact matching log interests. Consolidated filters -/// may be safe supersets; [`Self::route_log`] always re-checks the original -/// [`LogInterest`] and its local matcher before returning a route. -pub struct ReactiveRegistry { - handlers: BTreeMap>, - handler_positions: HashMap, - next_handler_position: u128, - indexed_log_handlers: HashMap>, - fallback_log_handlers: BTreeSet, - data_slice_shapes: HashMap<(usize, usize), usize>, +/// The gate diffs the on-chain root **across time** (never local-vs-chain, per +/// spec §6): it persists the *observed* root as a baseline and compares +/// `root_now` to it. This is a currency gate, not a completeness gate. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +struct TrackedRoot { + last_root: B256, + last_block: u64, + balance: U256, + nonce: u64, + code_hash: B256, } -struct RegisteredHandler { - id: HandlerId, - handler: Arc>, - interests: Vec>, - has_log_interests: bool, - log_route_index: Option, +/// Request for authoritative state repair. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ResyncRequest { + /// Resync id. + pub id: ResyncId, + /// Reason for the request. + pub reason: ResyncReason, + /// Block selection for the read. + pub block: ResyncBlock, + /// Targets to resync. + pub targets: Vec, + /// Scheduling priority. + pub priority: ResyncPriority, } -impl Default for ReactiveRegistry { - fn default() -> Self { - Self::new() - } -} +/// Resync id. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct ResyncId(String); -impl ReactiveRegistry { - /// Create an empty registry. - pub fn new() -> Self { - Self { - handlers: BTreeMap::new(), - handler_positions: HashMap::new(), - next_handler_position: 0, - indexed_log_handlers: HashMap::new(), - fallback_log_handlers: BTreeSet::new(), - data_slice_shapes: HashMap::new(), - } +impl ResyncId { + /// Create a resync id. + pub fn new(id: impl Into) -> Self { + Self(id.into()) } +} - /// Register a handler, preserving registration order. +/// Reason for a resync request. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub enum ResyncReason { + /// Handler requested repair. + HandlerRequested, + /// State effect could not be applied completely. + SkippedStateEffect, + /// A missed block range was detected; caller-scheduled repair. /// - /// Duplicate handler ids are rejected with - /// [`RegisterError::DuplicateHandler`]. - pub fn register_handler( - &mut self, - handler: Arc>, - ) -> Result<(), RegisterError> { - let id = handler.id(); - if self.handler_positions.contains_key(&id) { - return Err(RegisterError::DuplicateHandler(id)); - } - let interests = handler.interests(); - let has_log_interests = interests - .iter() - .any(|interest| matches!(interest, ReactiveInterest::Logs(_))); - let log_route_index = handler.log_route_index(); - if self.next_handler_position == u128::MAX { - self.compact_handler_positions(); - } - let position = self.next_handler_position; - self.next_handler_position += 1; - self.handler_positions.insert(id.clone(), position); - if let Some(index) = &log_route_index { - for key in index.keys() { - if let LogRouteKey::DataSlice { offset, value } = key { - *self - .data_slice_shapes - .entry((*offset, value.len())) - .or_default() += 1; - } - self.indexed_log_handlers - .entry(key.clone()) - .or_default() - .insert(position); - } - } else if has_log_interests { - self.fallback_log_handlers.insert(position); - } - self.handlers.insert( - position, - RegisteredHandler { - id, - handler, - interests, - has_log_interests, - log_route_index, - }, - ); - Ok(()) - } - - /// Remove one handler by id, leaving all other handlers and interests intact. + /// The runtime does not fabricate a targetless [`ResyncRequest`] for a missed + /// range (there are no known targets to resync). This reason is provided so a + /// caller building its own repair in response to a + /// [`ReactiveReport::MissedBlockRange`] can attribute it. + MissedBlockRange, + /// A tracked account's storage root moved with no covering decoder. /// - /// Returns the removed handler when the id was registered. Cache eviction is - /// intentionally outside this API: unregistering stops future routing and - /// decode for the handler only. - pub fn unregister_handler(&mut self, id: &HandlerId) -> Option>> { - let position = self.handler_positions.remove(id)?; - let registered = self.handlers.remove(&position)?; - if let Some(index) = ®istered.log_route_index { - for key in index.keys() { - let remove_bucket = self - .indexed_log_handlers - .get_mut(key) - .is_some_and(|owners| { - owners.remove(&position); - owners.is_empty() - }); - if remove_bucket { - self.indexed_log_handlers.remove(key); - } - if let LogRouteKey::DataSlice { offset, value } = key { - let shape = (*offset, value.len()); - let remove_shape = - self.data_slice_shapes.get_mut(&shape).is_some_and(|count| { - *count -= 1; - *count == 0 - }); - if remove_shape { - self.data_slice_shapes.remove(&shape); - } - } - } - } else { - self.fallback_log_handlers.remove(&position); - } - Some(registered.handler) - } - - /// Return true when `id` is currently registered. - pub fn contains_handler(&self, id: &HandlerId) -> bool { - self.handler_positions.contains_key(id) - } + /// Emitted by the per-block root gate (Phase-8 step 4). A + /// [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked account's + /// `storageHash` moved between the adopted baseline and the current canonical + /// block, yet no decoder wrote that account during the block — a coverage gap. + /// The gate schedules a resync with this reason to re-read the account + /// authoritatively and self-heal the blind spot. Also used for the + /// [`Scalars`](TrackingPolicy::Scalars) account-field freshness path. + RootMoved, + /// Caller-defined reason. + Custom(String), +} - /// Ids of all registered handlers, in registration (= routing) order. - pub fn handler_ids(&self) -> Vec { - self.handlers - .values() - .map(|handler| handler.id.clone()) - .collect() - } +/// Block target for a resync. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum ResyncBlock { + /// Latest block. + Latest, + /// Current provider pre-confirmation state. + Pending, + /// Safe head. + Safe, + /// Finalized head. + Finalized, + /// Block number. + Number(u64), + /// Block hash and number. + Hash { + /// Block number. + number: u64, + /// Block hash. + hash: B256, + /// Require the hash to still be canonical. + require_canonical: bool, + }, +} - /// Borrow the interests owned by one handler. - pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest]> { - self.handler_positions - .get(id) - .and_then(|position| self.handlers.get(position)) - .map(|registered| registered.interests.as_slice()) - } +/// State target for a resync. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum ResyncTarget { + /// One storage slot. + StorageSlot { + /// Contract address. + address: Address, + /// Storage slot. + slot: U256, + }, + /// Multiple storage slots on one contract. + StorageSlots { + /// Contract address. + address: Address, + /// Storage slots. + slots: Vec, + }, + /// Account fields. + Account { + /// Account address. + address: Address, + /// Fields to resync. + fields: AccountFieldMask, + }, +} - /// Return all registered interests in handler registration order. - pub fn interests(&self) -> Vec> { - self.handlers - .values() - .flat_map(|handler| handler.interests.clone()) - .collect() - } +/// Account fields requested by a resync. +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, +)] +pub struct AccountFieldMask { + /// Balance field. + pub balance: bool, + /// Nonce field. + pub nonce: bool, + /// Code field. + pub code: bool, +} - /// Return consolidated provider-side log filters. - /// - /// Filters are emitted in deterministic first-registration order by - /// compatible block option. Within each returned filter, address and topic - /// sets are unioned independently, which can intentionally overfetch. Use - /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s. - pub fn log_subscription_filters(&self) -> Vec { - let mut filters = Vec::new(); - for interest in self.log_interests() { - merge_log_subscription_filter(&mut filters, &interest.provider_filter); - } - filters - } +/// Resync priority. +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] +pub enum ResyncPriority { + /// Low priority. + Low, + /// Normal priority. + #[default] + Normal, + /// High priority. + High, +} - /// Route a log to exact matching handler interests. - /// - /// Routes are returned in handler registration order. Each handler appears - /// at most once for a log, using the first matching log interest declared by - /// that handler. - pub fn route_log(&self, log: &Log) -> Vec { - self.log_handler_candidates(log) - .into_iter() - .filter_map(|handler| handler.route_log(log)) - .collect() - } +/// Rich invalidation request lowered to [`StateUpdate::Purge`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InvalidationRequest { + /// Purge scope. + pub scope: PurgeScope, + /// Address to purge. + pub address: Address, + /// Reason for reporting. + pub reason: InvalidationReason, +} - fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler> { - let mut indexed_positions = Vec::new(); - if let Some(indexed) = self - .indexed_log_handlers - .get(&LogRouteKey::Emitter(log.address())) - { - indexed_positions.extend(indexed.iter().copied()); - } - for (index, value) in log.topics().iter().copied().enumerate() { - if let Some(indexed) = self - .indexed_log_handlers - .get(&LogRouteKey::Topic { index, value }) - { - indexed_positions.extend(indexed.iter().copied()); - } - } - let data = log.inner.data.data.as_ref(); - for &(offset, len) in self.data_slice_shapes.keys() { - let Some(end) = offset.checked_add(len) else { - continue; - }; - let Some(value) = data.get(offset..end) else { - continue; - }; - if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice { - offset, - value: value.to_vec(), - }) { - indexed_positions.extend(indexed.iter().copied()); - } - } - if indexed_positions.is_empty() { - if self.fallback_log_handlers.is_empty() { - return Vec::new(); - } - if !self.indexed_log_handlers.is_empty() { - return self - .fallback_log_handlers - .iter() - .filter_map(|position| self.handlers.get(position)) - .collect(); - } - return self - .handlers - .values() - .filter(|handler| handler.has_log_interests && handler.log_route_index.is_none()) - .collect(); - } +/// Invalidation reason. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum InvalidationReason { + /// Handler requested invalidation. + HandlerRequested, + /// Reorg invalidation. + Reorg, + /// Caller-defined reason. + Custom(String), +} - indexed_positions.extend(self.fallback_log_handlers.iter().copied()); - indexed_positions.sort_unstable(); - indexed_positions.dedup(); - indexed_positions - .into_iter() - .filter_map(|position| self.handlers.get(&position)) - .collect() - } +/// Speculative signal emitted by handlers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SpeculativeRequest { + /// Speculative request id. + pub id: SpeculativeId, + /// Input that triggered the request. + pub input_ref: InputRef, + /// Labels for downstream routing. + pub labels: Vec, +} - fn handlers(&self) -> impl Iterator> { - self.handlers.values() - } +/// Speculative request id. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SpeculativeId(String); - fn log_interests(&self) -> impl Iterator { - self.handlers.values().flat_map(|handler| { - handler - .interests - .iter() - .filter_map(|interest| match interest { - ReactiveInterest::Logs(interest) => Some(interest), - ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None, - }) - }) +impl SpeculativeId { + /// Create a speculative id. + pub fn new(id: impl Into) -> Self { + Self(id.into()) } +} - fn compact_handler_positions(&mut self) { - let handlers = std::mem::take(&mut self.handlers); - self.handler_positions.clear(); - self.indexed_log_handlers.clear(); - self.fallback_log_handlers.clear(); - self.data_slice_shapes.clear(); - - for (position, (_, handler)) in handlers.into_iter().enumerate() { - let position = position as u128; - self.handler_positions.insert(handler.id.clone(), position); - if let Some(index) = &handler.log_route_index { - for key in index.keys() { - if let LogRouteKey::DataSlice { offset, value } = key { - *self - .data_slice_shapes - .entry((*offset, value.len())) - .or_default() += 1; - } - self.indexed_log_handlers - .entry(key.clone()) - .or_default() - .insert(position); - } - } else if handler.has_log_interests { - self.fallback_log_handlers.insert(position); - } - self.handlers.insert(position, handler); - } - self.next_handler_position = self.handlers.len() as u128; - } +/// Configuration for [`ReactiveRuntime`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReactiveConfig { + /// Hook backpressure policy. **Reserved — currently has no effect.** Hook + /// dispatch is synchronous today (every report is delivered to every hook in + /// order), so this field is a no-op placeholder for a future async dispatcher. + /// Setting it to anything other than the default does not change behavior. + pub hook_backpressure: HookBackpressure, + /// Reorg journal depth: the number of recent canonical blocks whose effects + /// are journaled for rollback. This is **load-bearing** for reorg recovery: + /// only blocks still resident in the journal can be recovered. A reorg deeper + /// than `journal_depth` recovers the blocks still in the journal and leaves + /// the aged-out blocks' effects in place — they are **neither rolled back nor + /// purged**, so the freshness/validation loop is the only backstop for that + /// span. `0` disables journaling entirely: no reorg is rolled back or purged. + /// + /// Set `journal_depth` to exceed the deepest reorg you intend to recover + /// precisely. When a reorg references a block that is no longer in the journal, + /// the runtime emits a `tracing::warn!` so the under-recovery is observable + /// rather than silent. Checkpointed engine ingestion is stricter: explicit + /// reorgs, implicit parent replacements, and removed/reorged records whose + /// rollback proof falls outside the retained effect journal are rejected + /// before mutation, durable save, or acknowledgement. Align this depth with + /// the complete reorg horizon promised by the subscriber. + pub journal_depth: usize, } -impl ReactiveRuntime { - /// Create an empty runtime. - pub fn new(config: ReactiveConfig) -> Self { +impl Default for ReactiveConfig { + fn default() -> Self { Self { - registry: ReactiveRegistry::new(), - hooks: Vec::new(), - config, - journal: VecDeque::new(), - pending_resyncs: Vec::new(), - health: CacheHealth::Healthy, - metrics: CacheMetrics::default(), - freshness: None, - tracking: HashMap::new(), - tracked_roots: HashMap::new(), - root_gate_cadence: RootGateCadence::default(), - last_gate_block: None, - touched_since_gate: HashSet::new(), + hook_backpressure: HookBackpressure::Block, + journal_depth: 64, } } +} - /// Track `address` under `policy` for the per-block root gate (Phase-8 step 4). - /// - /// Tracking is strictly opt-in: a runtime with no tracked accounts runs the - /// gate as a no-op. Registering an account clears any baseline it held (a - /// policy change re-adopts on the next probe rather than diffing against a - /// baseline captured under the old policy). Each [`RootGateCadence`] - /// firing, the gate - /// probes tracked [`WholeAccount`](TrackingPolicy::WholeAccount) and - /// [`Scalars`](TrackingPolicy::Scalars) accounts' roots/fields via the - /// account-proof seam and, on a move no decoder covered, emits a - /// [`ReactiveReport::CoverageGap`] and schedules a - /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots) - /// accounts are never root-gated (spec Decision 3). - pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) { - self.tracking.insert(address, policy); - self.tracked_roots.remove(&address); - } - - /// Stop tracking `address`, dropping its policy and any adopted baseline. - /// - /// Returns `true` if the account was tracked. - pub fn untrack_account(&mut self, address: Address) -> bool { - self.tracked_roots.remove(&address); - self.tracking.remove(&address).is_some() - } +/// Hook backpressure policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum HookBackpressure { + /// Block the producer until hooks are accepted. + Block, + /// Drop the newest report under pressure. + DropNewest, + /// Drop the oldest report under pressure. + DropOldest, + /// Return an error under pressure. + Error, +} - /// Set how often the root gate probes tracked accounts (default: - /// [`RootGateCadence::default`] — every 16 canonical blocks; see the - /// [`RootGateCadence`] docs for why skipping blocks loses no detection). - /// - /// Reconfiguring resets the gate's window bookkeeping (the touched-address - /// accumulator and the last-fired block), so a stale window never leaks - /// into the new cadence: the next canonical block fires the gate. - pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) { - self.root_gate_cadence = cadence; - self.last_gate_block = None; - self.touched_since_gate.clear(); - } +/// Queryable coarse health of the reactive cache. +/// +/// The runtime starts [`Healthy`](CacheHealth::Healthy) and transitions to a +/// degraded or unhealthy state when it detects that its recovery guarantees no +/// longer hold (for example a reorg that runs deeper than the journal, so some +/// dropped effects are neither rolled back nor purged). Later waves report +/// missed-range and coverage-gap conditions into the same state machine. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub enum CacheHealth { + /// All recovery guarantees hold; the cache is fully self-consistent. + #[default] + Healthy, + /// A recoverable inconsistency was detected (for example under-recovered + /// reorg effects); `since_block` records the block that triggered the + /// transition. + Degraded { + /// Block number at which the degradation was first observed. + since_block: u64, + }, + /// A more serious inconsistency was detected; `since_block` records the + /// block that triggered the transition. + Unhealthy { + /// Block number at which the unhealthy condition was first observed. + since_block: u64, + }, +} - /// The configured [`RootGateCadence`]. - pub fn root_gate_cadence(&self) -> RootGateCadence { - self.root_gate_cadence - } +/// Point-in-time copy of the reactive runtime's observability counters. +/// +/// Returned by [`ReactiveRuntime::metrics`]. Each field is a monotonically +/// increasing count over the lifetime of the runtime. Counters wired by later +/// waves (missed-range detection, storage-hash coverage gaps, stale-verdict +/// tracking) remain zero until those waves land. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub struct CacheMetricsSnapshot { + /// Reorgs that ran deeper than the journal, so aged-out effects could not be + /// rolled back or purged. + pub deep_reorgs: u64, + /// Reorgs for which a [`ReorgReport`] recovery ran (including deep reorgs). + pub reorgs_recovered: u64, + /// Storage resync targets considered by the resync execution pass. + pub resync_requests: u64, + /// Storage resync targets that could not be fetched or applied. + pub resync_failures: u64, + /// Ranges of blocks the runtime detected it did not observe (reserved). + pub missed_ranges: u64, + /// Storage-hash coverage gaps detected (reserved). + pub coverage_gaps: u64, + /// Pending-source inputs that attempted a canonical cache effect. + pub pending_contamination: u64, + /// Verdicts served past their freshness horizon (reserved). + pub stale_verdicts: u64, +} - /// Enable freshness stamping of canonical event-derived writes (opt-in). - /// - /// Installs a [`FreshnessRegistry`] the runtime owns; while it is present, - /// applying a canonical handler storage-slot effect for a block `N` stamps the - /// touched `(address, slot)` as - /// [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`. - /// The slot is therefore not volatile *at* `N` (event-maintained, no need to - /// re-verify) but ages to volatile once the clock passes `N`. - /// - /// Idempotent: if a registry is already installed it is left untouched, so an - /// existing registry (and any stamps it holds) is never clobbered. - pub fn enable_freshness_stamping(&mut self) { - if self.freshness.is_none() { - self.freshness = Some(FreshnessRegistry::new()); - } - } +/// Internal atomic-backed counters mirrored by [`CacheMetricsSnapshot`]. +/// +/// Fields are [`AtomicU64`] so counters can be incremented behind a shared +/// reference; [`ReactiveRuntime::metrics`] loads each with [`Ordering::Relaxed`] +/// into a plain [`CacheMetricsSnapshot`]. +#[derive(Debug, Default)] +struct CacheMetrics { + deep_reorgs: AtomicU64, + reorgs_recovered: AtomicU64, + resync_requests: AtomicU64, + resync_failures: AtomicU64, + missed_ranges: AtomicU64, + coverage_gaps: AtomicU64, + pending_contamination: AtomicU64, + stale_verdicts: AtomicU64, +} - /// Borrow the runtime's freshness registry, if stamping was enabled. - /// - /// Returns `None` unless - /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called. - pub fn freshness(&self) -> Option<&FreshnessRegistry> { - self.freshness.as_ref() +impl CacheMetrics { + fn snapshot(&self) -> CacheMetricsSnapshot { + CacheMetricsSnapshot { + deep_reorgs: self.deep_reorgs.load(Ordering::Relaxed), + reorgs_recovered: self.reorgs_recovered.load(Ordering::Relaxed), + resync_requests: self.resync_requests.load(Ordering::Relaxed), + resync_failures: self.resync_failures.load(Ordering::Relaxed), + missed_ranges: self.missed_ranges.load(Ordering::Relaxed), + coverage_gaps: self.coverage_gaps.load(Ordering::Relaxed), + pending_contamination: self.pending_contamination.load(Ordering::Relaxed), + stale_verdicts: self.stale_verdicts.load(Ordering::Relaxed), + } } - /// Mutably borrow the runtime's freshness registry, if stamping was enabled. - /// - /// Returns `None` unless - /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called. - pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> { - self.freshness.as_mut() + fn restore(&self, snapshot: CacheMetricsSnapshot) { + self.deep_reorgs + .store(snapshot.deep_reorgs, Ordering::Relaxed); + self.reorgs_recovered + .store(snapshot.reorgs_recovered, Ordering::Relaxed); + self.resync_requests + .store(snapshot.resync_requests, Ordering::Relaxed); + self.resync_failures + .store(snapshot.resync_failures, Ordering::Relaxed); + self.missed_ranges + .store(snapshot.missed_ranges, Ordering::Relaxed); + self.coverage_gaps + .store(snapshot.coverage_gaps, Ordering::Relaxed); + self.pending_contamination + .store(snapshot.pending_contamination, Ordering::Relaxed); + self.stale_verdicts + .store(snapshot.stale_verdicts, Ordering::Relaxed); } +} - /// Return the current queryable [`CacheHealth`] of the runtime. - pub fn health(&self) -> CacheHealth { - self.health - } +/// Runtime report. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub enum ReactiveReport { + /// Input was accepted after deduplication. + Input(InputReport), + /// Handlers produced outcomes. + Decoded(DecodedReport), + /// Direct state effects were applied. + Applied(AppliedReport), + /// Resync request was scheduled or completed. + Resynced(ResyncReport), + /// Block-level processing completed. + BlockCommitted(BlockReport), + /// Reorg processing report. + Reorg(ReorgReport), + /// Ordered source control accepted by the runtime. + ChainControl(ChainControlReport), + /// A forward gap in the canonical block sequence was detected: blocks between + /// the last-seen head and an arriving block were never observed. + MissedBlockRange(MissedRangeReport), + /// Cache health transitioned between states. + Health(HealthReport), + /// A tracked account's storage root moved with no covering decoder — a + /// coverage gap the per-block root gate detected (Phase-8 step 4). + CoverageGap(CoverageGapReport), + /// Runtime or handler error. + Error(ReactiveErrorReport), +} - /// Return a point-in-time snapshot of the runtime's observability counters. - pub fn metrics(&self) -> CacheMetricsSnapshot { - self.metrics.snapshot() - } +/// Report emitted after an ordered source control is accepted. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChainControlReport { + /// Control in its original delivery order. + pub control: ChainControl, +} - /// Complete the caller-driven self-heal by returning health to - /// [`CacheHealth::Healthy`]. - /// - /// A trust-loss event (a reorg deeper than the journal, or a detected missed - /// block range) escalates health toward [`CacheHealth::Unhealthy`] as a - /// "stop until rebuilt" signal that the caller must act on. Once the caller - /// has resynced or rebuilt the affected state, it invokes this to clear the - /// signal. It does not emit a [`ReactiveReport::Health`] report, since it is - /// called outside an ingest cycle. - pub fn reset_health(&mut self) { - self.health = CacheHealth::Healthy; - } +/// Input acceptance report. +#[derive(Clone, Debug)] +pub struct InputReport { + /// Input reference. + pub input_ref: InputRef, + /// Input context. + pub context: ReactiveContext, + /// Provider session that originated the input, when known. + pub provider: Option, + /// Network marker. + pub _network: PhantomData, +} - /// Escalate health one rung up the trust-loss ladder for a trust-loss event - /// observed at `block`, returning a [`ReactiveReport::Health`] report when the - /// state actually changes. - /// - /// The ladder is: - /// - [`Healthy`](CacheHealth::Healthy) -> [`Degraded`](CacheHealth::Degraded) - /// - [`Degraded`](CacheHealth::Degraded) -> [`Unhealthy`](CacheHealth::Unhealthy) - /// - [`Unhealthy`](CacheHealth::Unhealthy) -> no change (`None`) - /// - /// A first event degrades; a second escalates to the terminal - /// [`Unhealthy`](CacheHealth::Unhealthy) stop signal. This is shared by both - /// trust-loss paths (deep reorg beyond the journal and missed-range - /// detection) so mixed event types climb the same ladder. - fn escalate_trust(&mut self, block: u64) -> Option>> { - let to = match self.health { - CacheHealth::Healthy => CacheHealth::Degraded { since_block: block }, - CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block }, - CacheHealth::Unhealthy { .. } => return None, - }; - self.transition_health(to, Some(block)) - } +/// Decoding report. +#[derive(Clone, Debug)] +pub struct DecodedReport { + /// Input reference. + pub input_ref: InputRef, + /// Handler ids that matched the input. + pub handler_ids: Vec, + /// Network marker. + pub _network: PhantomData, +} - /// Transition health to `to`, returning a [`ReactiveReport::Health`] report - /// when the state actually changes. - /// - /// The returned report must be threaded into the ingest cycle's dispatched - /// reports so it reaches hooks and appears in - /// [`ReactiveBatchReport::reports`]. Returns `None` when `to` equals the - /// current state (no transition, no report). - fn transition_health( - &mut self, - to: CacheHealth, - block: Option, - ) -> Option>> { - if to == self.health { - return None; - } - let from = self.health; - self.health = to; - Some(Arc::new(ReactiveReport::Health(HealthReport { - from, - to, - block, - _network: PhantomData, - }))) - } +/// Applied state report. +#[derive(Clone, Debug)] +pub struct AppliedReport { + /// Input reference. + pub input_ref: InputRef, + /// Handler that produced the applied effects. + pub handler_id: HandlerId, + /// State effect quality. + pub quality: StateEffectQuality, + /// Labels emitted by the handler. + pub tags: Vec, + /// Merged state diff from applied updates and invalidations. + pub diff: StateDiff, + /// State updates applied through the cache. + pub state_updates: Vec, + /// Invalidation requests lowered to purge updates. + pub invalidations: Vec, + /// Resync requests surfaced for a scheduler. + pub resyncs: Vec, + /// Speculative requests surfaced for downstream users. + pub speculative: Vec, + /// Hook signals emitted by the handler. + pub hook_signals: Vec, + /// Network marker. + pub _network: PhantomData, +} - /// Register a handler. - pub fn register_handler( - &mut self, - handler: Arc>, - ) -> Result<(), RegisterError> { - self.registry.register_handler(handler) - } +/// Report of the storage resync requests executed during an ingest cycle: the +/// requests considered, the authoritative updates built from successful fetches +/// (and their applied diff), and any targets that could not be resynced. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ResyncReport { + /// Requests considered by the resync execution pass. + pub requested: Vec, + /// Authoritative state updates built from successful resync fetches. + pub state_updates: Vec, + /// Diff returned by applying [`state_updates`](Self::state_updates). + pub diff: StateDiff, + /// Targets that could not be resynced. + pub failed: Vec, +} - /// Remove one handler from the runtime registry without resetting runtime state. - /// - /// This delegates to [`ReactiveRegistry::unregister_handler`] only. It does - /// not clear the reorg journal, health, metrics, hooks, pending resyncs, - /// tracking policy, freshness registry, or root-gate baselines, and it does - /// not purge [`EvmCache`] state. Callers that want cache eviction must issue - /// explicit `StateUpdate::purge` updates or use cache purge APIs separately. - pub fn unregister_handler(&mut self, id: &HandlerId) -> Option>> { - self.registry.unregister_handler(id) +/// One resync target that could not be fetched or applied. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResyncFailure { + /// Request that produced the failed target. + pub request_id: ResyncId, + /// Block selection used for the failed target. + pub block: ResyncBlock, + /// Target that could not be resynced. + pub target: ResyncTarget, + /// Stable failure classification for retry policy and metrics. + pub kind: ResyncFailureKind, + /// Human-readable failure reason. + pub message: String, +} + +/// Stable classification for a failed resync target. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ResyncFailureKind { + /// A storage target could not be fetched because no storage batch fetcher is configured. + MissingStorageFetcher, + /// The storage batch fetcher returned an error for the requested slot. + StorageFetchFailed, + /// The storage batch fetcher did not return a result for the requested slot. + StorageFetchOmitted, + /// An account target could not be fetched because no account proof fetcher is configured. + MissingAccountFetcher, + /// The account proof fetcher returned an error for the requested address. + AccountFetchFailed, + /// The account proof fetcher did not return a result for the requested address. + AccountFetchOmitted, +} + +/// Block processing report. +#[derive(Clone, Debug)] +pub struct BlockReport { + /// Block reference, when known. + pub block: Option, + /// Input references committed for the block. + pub inputs: Vec, + /// Network marker. + pub _network: PhantomData, +} + +/// Report of a detected reorg and the recovery it performed: the dropped +/// block(s) and inputs, the exact rollback updates applied for reversible dropped +/// effects, the conservative purge updates for irreversible ones, the canceled +/// hash-pinned resyncs, and why recovery ran. +/// +/// Recovery only covers blocks still resident in the journal. If a reorg runs +/// deeper than [`ReactiveConfig::journal_depth`], the aged-out blocks do not +/// appear here and their effects are neither rolled back nor purged (the runtime +/// logs a `tracing::warn!` in that case); the freshness/validation loop is the +/// backstop for that span. Checkpointed engine ingestion rejects explicit, +/// implicit-parent, and removed-log recovery outside the retained journal +/// instead of producing and durably acknowledging a partial report. +/// Non-checkpointed ingestion still emits this report when no journal entry was +/// recoverable; in that case `dropped` identifies the signal/head when known, +/// while `dropped_blocks` and rollback effects are empty. +#[derive(Clone, Debug)] +pub struct ReorgReport { + /// First dropped block, when known. + pub dropped: Option, + /// Blocks dropped from the journal, in ascending journal order. + pub dropped_blocks: Vec, + /// Input references that belonged to dropped blocks. + pub dropped_inputs: Vec, + /// Exact rollback updates applied for reversible dropped effects. + pub rollback_updates: Vec, + /// Diff returned by applying [`rollback_updates`](Self::rollback_updates). + pub rollback_diff: StateDiff, + /// Conservative purge updates applied for irreversible dropped effects. + pub purge_updates: Vec, + /// Diff returned by applying [`purge_updates`](Self::purge_updates). + pub purge_diff: StateDiff, + /// Hash-pinned pending resync requests canceled because their block was dropped. + pub canceled_resyncs: Vec, + /// Reorg trigger. + pub reason: ReorgReason, + /// Network marker. + pub _network: PhantomData, +} + +/// Reason reorg recovery ran. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ReorgReason { + /// A provider emitted an Alloy removed log. + RemovedLog, + /// The input context explicitly marked an input as reorged. + ReorgedInput, + /// A canonical block did not connect to the journaled head. + ParentMismatch, + /// A subscriber delivered an explicit canonical branch transition. + Explicit, +} + +/// Report of a forward gap in the canonical block sequence: an arriving block +/// whose number is more than one past the last-seen head, so the blocks in +/// between were never observed (for example during a subscription disconnect). +/// +/// The arriving block is still accepted and applied — the chain extends — so this +/// report only makes the skipped span observable; it does not drop the block. The +/// span `from..=to` is inclusive of both endpoints. +#[derive(Clone, Debug)] +pub struct MissedRangeReport { + /// First skipped block (`last-seen block number + 1`). + pub from: u64, + /// Last skipped block (`arriving block number - 1`). + pub to: u64, + /// The arriving block's number. + pub block: u64, + /// Network marker. + pub _network: PhantomData, +} + +/// Report of a [`CacheHealth`] transition, emitted into the ingest cycle that +/// caused it and delivered to hooks through the normal dispatch path. +#[derive(Clone, Debug)] +pub struct HealthReport { + /// Health state before the transition. + pub from: CacheHealth, + /// Health state after the transition. + pub to: CacheHealth, + /// Block number associated with the transition, when known. + pub block: Option, + /// Network marker. + pub _network: PhantomData, +} + +/// Report that a tracked account's storage root moved on a canonical block that +/// no decoder covered — a coverage gap surfaced by the per-block root gate +/// (Phase-8 step 4). +/// +/// An account's `storageHash` is a collision-resistant commitment over all of its +/// storage, so a moved root proves *something* under the account changed. When +/// that account is [`WholeAccount`](TrackingPolicy::WholeAccount)-tracked and the +/// batch's touched-address set does not include it, the change arrived through a +/// path no decoder observed. The runtime emits this report (delivered through the +/// normal dispatch path so [`ReactiveHook::on_report`] observers see it), +/// increments [`CacheMetricsSnapshot::coverage_gaps`], and schedules a +/// [`ResyncReason::RootMoved`] repair to re-read the account authoritatively. +#[derive(Clone, Debug)] +pub struct CoverageGapReport { + /// The tracked account whose root moved with no covering decoder. + pub address: Address, + /// The canonical block number at which the gap was observed. + pub block: u64, + /// Network marker. + pub _network: PhantomData, +} + +/// Report of a non-fatal error surfaced during an ingest cycle, with the +/// associated input (when known) and a human-readable message. +#[derive(Clone, Debug)] +pub struct ReactiveErrorReport { + /// Input associated with the error, when known. + pub input_ref: Option, + /// Error message. + pub message: String, + /// Network marker. + pub _network: PhantomData, +} + +/// Batch report returned by [`ReactiveRuntime::ingest_batch`] and +/// [`ReactiveRuntime::ingest_batch_with_resync`]. +#[derive(Clone, Debug)] +pub struct ReactiveBatchReport { + /// Applied reports in commit order. + pub applied: Vec>, + /// Resync requests surfaced during the batch. + pub resyncs: Vec, + /// Speculative requests surfaced during the batch. + pub speculative: Vec, + /// Hook reports dispatched after mutation phases. + pub reports: Vec>>, +} + +impl Default for ReactiveBatchReport { + fn default() -> Self { + Self { + applied: Vec::new(), + resyncs: Vec::new(), + speculative: Vec::new(), + reports: Vec::new(), + } } +} - /// Return true when the runtime has a registered handler with `id`. - pub fn contains_handler(&self, id: &HandlerId) -> bool { - self.registry.contains_handler(id) +/// Error returned by a handler. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HandlerError { + message: String, +} + +impl HandlerError { + /// Create a handler error from a message. + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } } +} - /// Ids of all registered handlers, in registration (= routing) order. - pub fn handler_ids(&self) -> Vec { - self.registry.handler_ids() +impl fmt::Display for HandlerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.message.fmt(f) } +} - /// Borrow the interests owned by one registered handler. - pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest]> { - self.registry.handler_interests(id) +impl std::error::Error for HandlerError {} + +impl From for HandlerError { + fn from(message: String) -> Self { + Self::new(message) } +} - /// The most recently journaled canonical block, if any. - /// - /// This is the runtime's current chain position: the canonical block most - /// recently recorded by ingestion. Reorged blocks are dropped from the - /// journal during recovery, so a rolled-back head does not linger here. - /// [`ReactiveEngine::register_handler`] uses it as the default backfill - /// anchor for handlers registered mid-lifecycle. `None` until the first - /// canonical input is journaled, and always `None` when - /// [`ReactiveConfig::journal_depth`] is 0 (journaling disabled). - pub fn last_canonical_block(&self) -> Option { - self.journal.back().map(|entry| entry.block.clone()) +impl From<&str> for HandlerError { + fn from(message: &str) -> Self { + Self::new(message) } +} - /// Return whether the retained reorg journal still contains an applied - /// record for `handler_id`. - /// - /// The record is retained even when the handler emitted only resync work, - /// so an owner can keep an explicit cache-eviction fence active for exactly +/// Runtime error. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ReactiveError { + /// Handler returned an error. + #[error("handler `{handler_id}` failed: {source}")] + HandlerFailed { + /// Handler id. + handler_id: HandlerId, + /// Handler error. + source: HandlerError, + }, + /// Multiple handlers emitted incompatible absolute writes for one input. + #[error( + "conflicting effects for input {input_ref:?} on target {target:?}: `{first}` vs `{second}`" + )] + ConflictingEffects { + /// Input reference. + input_ref: Box, + /// Conflicting target. + target: Box, + /// First handler id. + first: HandlerId, + /// Second handler id. + second: HandlerId, + }, + /// Pending inputs attempted to mutate canonical cache state. + #[error( + "pending input {input_ref:?} emitted invalid canonical effect `{effect_kind}` from `{handler_id}`" + )] + InvalidPendingEffect { + /// Input reference. + input_ref: Box, + /// Handler id. + handler_id: HandlerId, + /// Effect kind. + effect_kind: &'static str, + }, + /// A subscriber supplied payload metadata that is incomplete or + /// contradicts the accompanying context. + #[error("invalid reactive input record: {message}")] + InvalidInputRecord { + /// Human-readable invariant violation. + message: String, + }, + /// A source delivered a contradictory chain-lifecycle transition. + #[error("invalid chain control: {message}")] + InvalidChainControl { + /// Human-readable invariant violation. + message: String, + }, + /// Owner-scoped catch-up would mutate a historical block for which the + /// runtime has no rollback journal entry. + #[error( + "owner catch-up block {number} {hash} is outside the retained canonical rollback journal" + )] + OwnerCatchupOutsideJournal { + /// Catch-up block number. + number: u64, + /// Catch-up block hash. + hash: B256, + }, + /// Registration error. + #[error(transparent)] + Register(#[from] RegisterError), +} + +/// Handler registration error. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RegisterError { + /// Duplicate handler id. + #[error("handler id `{0}` is already registered")] + DuplicateHandler(HandlerId), +} + +/// Error returned when [`ReactiveEngine`] cannot register a handler on both the +/// runtime and subscriber sides. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ReactiveEngineRegisterError { + /// Runtime registry rejected the handler. + #[error(transparent)] + Register(#[from] RegisterError), + /// Subscriber rejected the handler's interests. + #[error(transparent)] + Subscriber(#[from] SubscriberError), + /// Owner-only history was not constrained to one hash-certified block that + /// remains in the runtime rollback journal. + #[error( + "owner backfill {start_block}..={end_block:?} must target exactly one hash-certified block in the retained rollback journal" + )] + BackfillOutsideJournal { + /// First requested block. + start_block: u64, + /// Inclusive requested upper bound, if bounded. + end_block: Option, + /// Hash-certified anchor supplied by the caller, if any. + retained_anchor: Option, + }, +} + +/// Error adopting an RPC snapshot as a runtime's canonical continuity +/// baseline. +#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum ReactiveBaselineError { + /// Runtime or engine delivery state already contains lifecycle work. + #[error("cannot adopt a canonical baseline after reactive processing has started")] + ActiveRuntime, + /// An exact repeat is allowed, but the requested baseline conflicts with + /// the previously adopted block. + #[error( + "canonical baseline conflicts with existing block {existing_number} {existing_hash} (requested {requested_number} {requested_hash})" + )] + ConflictingBaseline { + /// Existing baseline number. + existing_number: u64, + /// Existing baseline hash. + existing_hash: B256, + /// Requested baseline number. + requested_number: u64, + /// Requested baseline hash. + requested_hash: B256, + }, + /// Typed baseline and cache identify different chains. + #[error("baseline chain id {baseline_chain_id} does not match cache chain id {cache_chain_id}")] + CacheChainMismatch { + /// Chain declared by the baseline. + baseline_chain_id: u64, + /// Chain configured on the cache. + cache_chain_id: u64, + }, + /// The cache is not hash-pinned to the exact adopted canonical block. + #[error("cache block selector is not canonically hash-pinned to baseline {number} {hash}")] + CacheBlockMismatch { + /// Expected baseline number. + number: u64, + /// Expected baseline hash. + hash: B256, + }, +} + +/// Error returned by [`ReactiveEngine`] helpers that combine subscriber polling +/// and runtime ingestion. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ReactiveEngineError { + /// Subscriber polling failed. + #[error(transparent)] + Subscriber(#[from] SubscriberError), + /// Runtime ingestion failed. + #[error(transparent)] + Runtime(ReactiveError), + /// Canonical cold-start baseline adoption failed. + #[error(transparent)] + Baseline(#[from] ReactiveBaselineError), + /// Runtime ingestion succeeded, but its durable delivery acknowledgement + /// did not commit. The subscriber may replay the batch. + #[error("runtime ingestion succeeded but subscriber acknowledgement failed: {0}")] + Acknowledgement(#[source] SubscriberError), + /// Runtime ingestion succeeded, but the resulting cache state could not be + /// durably checkpointed. The engine retains the commit in memory and must + /// retry it before polling another batch. + #[error("runtime ingestion succeeded but durable checkpoint commit failed: {0}")] + Checkpoint(#[source] DurableCheckpointError), + /// A checkpointed ingest had no canonical block to bind the state to. + #[error("cannot durably checkpoint reactive state before observing a canonical block")] + MissingCheckpointBlock, + /// Speculative pre-confirmation state is intentionally excluded from + /// canonical durable checkpoints. + #[error("pre-confirmed Flashblock batches cannot be durably checkpointed")] + PreconfirmationNotCheckpointable, + /// Runtime rollback/finality state could not be encoded for the checkpoint. + #[error("failed to encode durable reactive runtime state: {0}")] + RuntimeCheckpoint(String), + /// A crash-safe checkpoint commit is pending, so the engine cannot switch + /// to ordinary acknowledgement ordering without first completing it. + #[error("cannot use ordinary ingestion while a durable checkpoint commit is pending")] + PendingCheckpointCommit, + /// An ordinary delivery acknowledgement is pending, so the engine cannot + /// switch to checkpointed ingestion and retroactively make it durable. + #[error("cannot use checkpointed ingestion while an ordinary acknowledgement is pending")] + PendingAcknowledgementCommit, + /// A caller attempted to use a raw ingestion helper with subscriber-owned + /// commit metadata. Only the combined polling helpers can preserve the + /// required ingest-before-checkpoint-before-acknowledgement ordering. + #[error( + "raw engine ingestion cannot consume delivery tokens or subscriber checkpoints; use a combined next_ingest helper" + )] + UncommittedDeliveryMetadata, + /// Subscriber and cache are bound to different chains. + #[error( + "subscriber chain id {subscriber_chain_id} does not match cache chain id {cache_chain_id}" + )] + SubscriberChainMismatch { + /// Chain reported by the subscriber. + subscriber_chain_id: u64, + /// Chain configured on the cache. + cache_chain_id: u64, + }, + /// Crash-safe checkpoint APIs require durable replay/resume semantics. + #[error("subscriber does not advertise durable replay support")] + SubscriberNotDurable, + /// A restored delivery token predates or otherwise lacks the core witness + /// needed to prove that a replay carries the same delivery. + #[error( + "committed delivery token has no delivery witness; replay cannot be acknowledged safely" + )] + MissingReplayWitness, + /// A source reused a committed token for different records, routing, + /// controls, chain identity, or provider resume state. + #[error("replayed delivery token does not match its committed delivery witness")] + ReplayDeliveryMismatch, + /// The stable delivery witness could not be encoded. + #[error("failed to encode durable delivery witness: {0}")] + DeliveryWitness(String), + /// A tokened network-generic header/body cannot be witnessed completely + /// without a source-supplied canonical wire commitment. + #[error( + "tokened block-header, full-block, or hydrated-transaction delivery requires an exact payload commitment" + )] + MissingPayloadCommitment, + /// Cache state changed after a batch was staged for a checkpoint. Retrying + /// would bind those unrelated mutations to the older delivery metadata. + #[error( + "cache changed while durable checkpoint commit was pending (staged generation {staged_generation}, current generation {current_generation})" + )] + PendingCheckpointCacheChanged { + /// Generation immediately after the staged batch was ingested. + staged_generation: u64, + /// Generation observed when checkpoint commit was retried. + current_generation: u64, + }, + /// Checkpointed ingestion cannot durably acknowledge a reorg when the + /// runtime no longer retains every potentially affected journal entry. + #[error( + "reorg after block {common_ancestor} exceeds the retained rollback journal (oldest retained block {oldest_journaled:?}, configured depth {journal_depth})" + )] + CheckpointReorgOutsideJournal { + /// Last block shared by the old and replacement branches. + common_ancestor: u64, + /// Oldest retained effect-bearing journal block, if any. + oldest_journaled: Option, + /// Configured maximum journal entries. + journal_depth: usize, + }, + /// Owner-scoped catch-up would mutate a historical block for which the + /// runtime has no rollback journal entry. + #[error( + "owner catch-up block {number} {hash} is outside the retained canonical rollback journal" + )] + OwnerCatchupOutsideJournal { + /// Catch-up block number. + number: u64, + /// Catch-up block hash. + hash: B256, + }, +} + +impl From for ReactiveEngineError { + fn from(error: ReactiveError) -> Self { + match error { + ReactiveError::OwnerCatchupOutsideJournal { number, hash } => { + Self::OwnerCatchupOutsideJournal { number, hash } + } + error => Self::Runtime(error), + } + } +} + +/// Error restoring a durable checkpoint anchor into an active runtime. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ReactiveCheckpointRestoreError { + /// A runtime with canonical journal state cannot be silently rewound. + #[error("cannot restore a durable checkpoint into a runtime with canonical journal state")] + ActiveRuntime, + /// Stored runtime recovery bytes were malformed or unsupported. + #[error("invalid durable reactive runtime state: {0}")] + InvalidRuntimeCheckpoint(String), + /// Checkpoint identity or cache restoration failed before activation. + #[error(transparent)] + Checkpoint(#[from] DurableCheckpointError), + /// Subscriber rejected the restored durable cursor or canonical position. + #[error("subscriber rejected durable resume position: {0}")] + Subscriber(#[source] SubscriberError), + /// Subscriber and checkpoint identities name different chains. + #[error( + "subscriber chain id {subscriber_chain_id} does not match checkpoint chain id {checkpoint_chain_id}" + )] + SubscriberChainMismatch { + /// Chain reported by the subscriber. + subscriber_chain_id: u64, + /// Chain committed by the checkpoint identity. + checkpoint_chain_id: u64, + }, + /// Restoring event continuity requires a durable replay-capable subscriber. + #[error("subscriber does not advertise durable replay support")] + SubscriberNotDurable, +} + +/// Result of one crash-safe subscriber ingest cycle. +#[derive(Clone, Debug)] +#[non_exhaustive] +pub enum CheckpointedIngest { + /// A new batch was ingested, durably checkpointed, and acknowledged. + Applied(ReactiveBatchReport), + /// The checkpoint already contained this replayed delivery token, so the + /// batch was acknowledged without applying its effects twice. + ReplayAcknowledged, +} + +/// Absolute write target used for conflict reports. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum EffectTarget { + /// Storage slot target. + StorageSlot { + /// Contract address. + address: Address, + /// Storage slot. + slot: U256, + }, + /// Account balance target. + AccountBalance { + /// Account address. + address: Address, + }, + /// Account nonce target. + AccountNonce { + /// Account address. + address: Address, + }, + /// Account code target. + AccountCode { + /// Account address. + address: Address, + }, + /// Masked storage slot target. + MaskedStorageSlot { + /// Contract address. + address: Address, + /// Storage slot. + slot: U256, + /// Bit mask. + mask: U256, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum AbsoluteValue { + U256(U256), + U64(u64), + Bytes(Bytes), +} + +/// Reactive runtime. +pub struct ReactiveRuntime { + registry: ReactiveRegistry, + hooks: Vec>>, + config: ReactiveConfig, + journal: VecDeque>, + coverage_head: Option, + pending_resyncs: Vec, + health: CacheHealth, + safe_head: Option, + finalized_head: Option, + metrics: CacheMetrics, + /// Opt-in freshness registry the runtime stamps for canonical event writes. + /// + /// `None` by default (behavior unchanged); populated by + /// [`enable_freshness_stamping`](Self::enable_freshness_stamping). When + /// present, applying a canonical handler storage-slot effect stamps the + /// touched `(address, slot)` as [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)` + /// so event-maintained slots stop being needlessly re-verified while aging to + /// volatile once the clock passes `N`. + freshness: Option, + /// Per-account tracking registry consulted by the per-block root gate + /// (Phase-8 step 4). Empty by default; populated by + /// [`track_account`](Self::track_account). When empty the gate is a no-op. + tracking: HashMap, + /// Per-account root/field baselines the gate diffs against across blocks. + /// Adopted on first probe and re-adopted on every observed move. + tracked_roots: HashMap, + /// How often the root gate fires (§6.2); see [`RootGateCadence`]. + root_gate_cadence: RootGateCadence, + /// Canonical block of the last root-gate firing. `None` until the first + /// firing (which happens at the first canonical block ever seen, so + /// baseline adoption never waits a full cadence window). + last_gate_block: Option, + /// Union of decoder-touched addresses since the last root-gate firing, + /// drained when it fires. Under cadence the gap rule "root moved ∧ addr ∉ + /// touched" must judge against every covered write in the window, or a + /// decoder-covered write in a skipped block would false-positive as a + /// [`ReactiveReport::CoverageGap`]. + touched_since_gate: HashSet
, + /// Disposable pre-confirmation branch layered over the canonical cache. + /// This is deliberately omitted from durable runtime checkpoints. + preconfirmed_branch: Option, +} + +#[derive(Clone)] +struct PreconfirmedBranch { + flashblock: FlashblockRef, + canonical_cache: EvmCacheStateSnapshot, +} + +#[derive(Clone, Debug)] +struct BlockJournal { + block: BlockRef, + inputs: Vec, + applied: Vec>, + handler_ids: Vec, + resynced: Vec, + rollback_diffs: Vec, +} + +const DURABLE_RUNTIME_CHECKPOINT_VERSION: u32 = 3; + +#[derive(serde::Serialize, serde::Deserialize)] +struct DurableRuntimeCheckpoint { + version: u32, + safe_head: Option, + finalized_head: Option, + health: CacheHealth, + pending_resyncs: Vec, + coverage_head: Option, + journal: Vec, + freshness: Option, + tracking: HashMap, + tracked_roots: HashMap, + root_gate_cadence: RootGateCadence, + last_gate_block: Option, + touched_since_gate: HashSet
, + metrics: CacheMetricsSnapshot, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct DurableBlockJournal { + block: BlockRef, + handler_ids: Vec, + rollback_diffs: Vec, +} + +struct DurableRuntimeRestorePlan { + checkpoint: Option, + fallback_history: Vec, +} + +impl DurableRuntimeRestorePlan { + fn canonical_history(&self) -> Vec { + self.checkpoint.as_ref().map_or_else( + || self.fallback_history.clone(), + |checkpoint| checkpoint.journal.iter().map(|entry| entry.block).collect(), + ) + } +} + +#[derive(Clone)] +struct ReactiveRuntimeState { + journal: VecDeque>, + coverage_head: Option, + pending_resyncs: Vec, + health: CacheHealth, + safe_head: Option, + finalized_head: Option, + freshness: Option, + tracking: HashMap, + tracked_roots: HashMap, + root_gate_cadence: RootGateCadence, + last_gate_block: Option, + touched_since_gate: HashSet
, + metrics: CacheMetricsSnapshot, +} + +#[derive(Clone)] +struct ChainControlState { + journal_invalidated_from: Option, + resolved_canonical_blocks: HashMap<(u64, B256), BlockRef>, +} + +/// Canonical branch fragments already rolled back by the current atomic batch. +/// +/// Providers commonly emit one removed notification per log after one signal +/// has already drained the complete dropped block (and every retained +/// descendant). Explicit reorg controls can be followed by the same redundant +/// lifecycle records. Exact identities decide whether removal recovery is +/// redundant; numeric spans are retained only as same-batch proof for a +/// parentless replacement after those exact journal entries were drained. +#[derive(Default)] +struct BatchDroppedCanonical { + identities: HashSet<(u64, B256)>, + implicit_spans: Vec<(u64, u64)>, +} + +impl BatchDroppedCanonical { + fn covers_implicit_number(&self, number: u64) -> bool { + self.implicit_spans + .iter() + .any(|(from, through)| number >= *from && number <= *through) + } + + fn contains(&self, block: &BlockRef) -> bool { + self.identities.contains(&(block.number, block.hash)) + } + + fn record_identity(&mut self, block: &BlockRef) { + self.identities.insert((block.number, block.hash)); + } + + fn record_explicit(&mut self, _common_ancestor: &BlockRef, old_tip: &BlockRef) { + self.identities.insert((old_tip.number, old_tip.hash)); + } + + fn record_drained(&mut self, blocks: &[BlockRef]) { + let Some(from) = blocks.iter().map(|block| block.number).min() else { + return; + }; + let through = blocks + .iter() + .map(|block| block.number) + .max() + .expect("a non-empty drained set has a maximum"); + self.implicit_spans.push((from, through)); + self.identities + .extend(blocks.iter().map(|block| (block.number, block.hash))); + } +} + +/// Registry and router for provider-neutral reactive handlers. +/// +/// The registry stores pure [`ReactiveHandler`]s in registration order, exposes +/// consolidated provider-side log filters for subscription setup, and routes +/// provider logs back to the exact matching log interests. Consolidated filters +/// may be safe supersets; [`Self::route_log`] always re-checks the original +/// [`LogInterest`] and its local matcher before returning a route. +pub struct ReactiveRegistry { + handlers: BTreeMap>, + handler_positions: HashMap, + next_handler_position: u128, + indexed_log_handlers: HashMap>, + fallback_log_handlers: BTreeSet, + data_slice_shapes: HashMap<(usize, usize), usize>, +} + +struct RegisteredHandler { + id: HandlerId, + handler: Arc>, + interests: Vec>, + has_log_interests: bool, + log_route_index: Option, +} + +impl Default for ReactiveRegistry { + fn default() -> Self { + Self::new() + } +} + +impl ReactiveRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + handlers: BTreeMap::new(), + handler_positions: HashMap::new(), + next_handler_position: 0, + indexed_log_handlers: HashMap::new(), + fallback_log_handlers: BTreeSet::new(), + data_slice_shapes: HashMap::new(), + } + } + + /// Register a handler, preserving registration order. + /// + /// Duplicate handler ids are rejected with + /// [`RegisterError::DuplicateHandler`]. + /// + /// # Errors + /// + /// Returns [`RegisterError::DuplicateHandler`] when the id is already + /// registered. + pub fn register_handler( + &mut self, + handler: Arc>, + ) -> Result<(), RegisterError> { + let id = handler.id(); + if self.handler_positions.contains_key(&id) { + return Err(RegisterError::DuplicateHandler(id)); + } + let interests = handler.interests(); + self.insert_handler_prepared(id, handler, interests); + Ok(()) + } + + fn insert_handler_prepared( + &mut self, + id: HandlerId, + handler: Arc>, + interests: Vec>, + ) { + debug_assert!(!self.handler_positions.contains_key(&id)); + let has_log_interests = interests + .iter() + .any(|interest| matches!(interest, ReactiveInterest::Logs(_))); + let log_route_index = handler.log_route_index(); + if self.next_handler_position == u128::MAX { + self.compact_handler_positions(); + } + let position = self.next_handler_position; + self.next_handler_position += 1; + self.handler_positions.insert(id.clone(), position); + if let Some(index) = &log_route_index { + for key in index.keys() { + if let LogRouteKey::DataSlice { offset, value } = key { + *self + .data_slice_shapes + .entry((*offset, value.len())) + .or_default() += 1; + } + self.indexed_log_handlers + .entry(key.clone()) + .or_default() + .insert(position); + } + } else if has_log_interests { + self.fallback_log_handlers.insert(position); + } + self.handlers.insert( + position, + RegisteredHandler { + id, + handler, + interests, + has_log_interests, + log_route_index, + }, + ); + } + + /// Remove one handler by id, leaving all other handlers and interests intact. + /// + /// Returns the removed handler when the id was registered. Cache eviction is + /// intentionally outside this API: unregistering stops future routing and + /// decode for the handler only. + pub fn unregister_handler(&mut self, id: &HandlerId) -> Option>> { + let position = self.handler_positions.remove(id)?; + let registered = self.handlers.remove(&position)?; + if let Some(index) = ®istered.log_route_index { + for key in index.keys() { + let remove_bucket = self + .indexed_log_handlers + .get_mut(key) + .is_some_and(|owners| { + owners.remove(&position); + owners.is_empty() + }); + if remove_bucket { + self.indexed_log_handlers.remove(key); + } + if let LogRouteKey::DataSlice { offset, value } = key { + let shape = (*offset, value.len()); + let remove_shape = + self.data_slice_shapes.get_mut(&shape).is_some_and(|count| { + *count -= 1; + *count == 0 + }); + if remove_shape { + self.data_slice_shapes.remove(&shape); + } + } + } + } else { + self.fallback_log_handlers.remove(&position); + } + Some(registered.handler) + } + + /// Return true when `id` is currently registered. + pub fn contains_handler(&self, id: &HandlerId) -> bool { + self.handler_positions.contains_key(id) + } + + /// Ids of all registered handlers, in registration (= routing) order. + pub fn handler_ids(&self) -> Vec { + self.handlers + .values() + .map(|handler| handler.id.clone()) + .collect() + } + + /// Borrow the interests owned by one handler. + pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest]> { + self.handler_positions + .get(id) + .and_then(|position| self.handlers.get(position)) + .map(|registered| registered.interests.as_slice()) + } + + /// Return all registered interests in handler registration order. + pub fn interests(&self) -> Vec> { + self.handlers + .values() + .flat_map(|handler| handler.interests.clone()) + .collect() + } + + /// Return consolidated provider-side log filters. + /// + /// Filters are emitted in deterministic first-registration order by + /// compatible block option. Within each returned filter, address and topic + /// sets are unioned independently, which can intentionally overfetch. Use + /// [`Self::route_log`] to enforce the exact original [`LogInterest`]s. + pub fn log_subscription_filters(&self) -> Vec { + let mut filters = Vec::new(); + for interest in self.log_interests() { + merge_log_subscription_filter(&mut filters, &interest.provider_filter); + } + filters + } + + /// Route a log to exact matching handler interests. + /// + /// Routes are returned in handler registration order. Each handler appears + /// at most once for a log, using the first matching log interest declared by + /// that handler. + pub fn route_log(&self, log: &Log) -> Vec { + self.log_handler_candidates(log) + .into_iter() + .filter_map(|handler| handler.route_log(log)) + .collect() + } + + fn log_handler_candidates(&self, log: &Log) -> Vec<&RegisteredHandler> { + let mut indexed_positions = Vec::new(); + if let Some(indexed) = self + .indexed_log_handlers + .get(&LogRouteKey::Emitter(log.address())) + { + indexed_positions.extend(indexed.iter().copied()); + } + for (index, value) in log.topics().iter().copied().enumerate() { + if let Some(indexed) = self + .indexed_log_handlers + .get(&LogRouteKey::Topic { index, value }) + { + indexed_positions.extend(indexed.iter().copied()); + } + } + let data = log.inner.data.data.as_ref(); + for &(offset, len) in self.data_slice_shapes.keys() { + let Some(end) = offset.checked_add(len) else { + continue; + }; + let Some(value) = data.get(offset..end) else { + continue; + }; + if let Some(indexed) = self.indexed_log_handlers.get(&LogRouteKey::DataSlice { + offset, + value: value.to_vec(), + }) { + indexed_positions.extend(indexed.iter().copied()); + } + } + if indexed_positions.is_empty() { + if self.fallback_log_handlers.is_empty() { + return Vec::new(); + } + if !self.indexed_log_handlers.is_empty() { + return self + .fallback_log_handlers + .iter() + .filter_map(|position| self.handlers.get(position)) + .collect(); + } + return self + .handlers + .values() + .filter(|handler| handler.has_log_interests && handler.log_route_index.is_none()) + .collect(); + } + + indexed_positions.extend(self.fallback_log_handlers.iter().copied()); + indexed_positions.sort_unstable(); + indexed_positions.dedup(); + indexed_positions + .into_iter() + .filter_map(|position| self.handlers.get(&position)) + .collect() + } + + fn handlers(&self) -> impl Iterator> { + self.handlers.values() + } + + fn log_interests(&self) -> impl Iterator { + self.handlers.values().flat_map(|handler| { + handler + .interests + .iter() + .filter_map(|interest| match interest { + ReactiveInterest::Logs(interest) => Some(interest), + ReactiveInterest::Blocks(_) | ReactiveInterest::PendingTransactions(_) => None, + }) + }) + } + + fn compact_handler_positions(&mut self) { + let handlers = std::mem::take(&mut self.handlers); + self.handler_positions.clear(); + self.indexed_log_handlers.clear(); + self.fallback_log_handlers.clear(); + self.data_slice_shapes.clear(); + + for (position, (_, handler)) in handlers.into_iter().enumerate() { + let position = position as u128; + self.handler_positions.insert(handler.id.clone(), position); + if let Some(index) = &handler.log_route_index { + for key in index.keys() { + if let LogRouteKey::DataSlice { offset, value } = key { + *self + .data_slice_shapes + .entry((*offset, value.len())) + .or_default() += 1; + } + self.indexed_log_handlers + .entry(key.clone()) + .or_default() + .insert(position); + } + } else if handler.has_log_interests { + self.fallback_log_handlers.insert(position); + } + self.handlers.insert(position, handler); + } + self.next_handler_position = self.handlers.len() as u128; + } +} + +impl ReactiveRuntime { + /// Create an empty runtime. + pub fn new(config: ReactiveConfig) -> Self { + Self { + registry: ReactiveRegistry::new(), + hooks: Vec::new(), + config, + journal: VecDeque::new(), + coverage_head: None, + pending_resyncs: Vec::new(), + health: CacheHealth::Healthy, + safe_head: None, + finalized_head: None, + metrics: CacheMetrics::default(), + freshness: None, + tracking: HashMap::new(), + tracked_roots: HashMap::new(), + root_gate_cadence: RootGateCadence::default(), + last_gate_block: None, + touched_since_gate: HashSet::new(), + preconfirmed_branch: None, + } + } + + fn checkpoint_state(&self) -> ReactiveRuntimeState { + ReactiveRuntimeState { + journal: self.journal.clone(), + coverage_head: self.coverage_head, + pending_resyncs: self.pending_resyncs.clone(), + health: self.health, + safe_head: self.safe_head, + finalized_head: self.finalized_head, + freshness: self.freshness.clone(), + tracking: self.tracking.clone(), + tracked_roots: self.tracked_roots.clone(), + root_gate_cadence: self.root_gate_cadence, + last_gate_block: self.last_gate_block, + touched_since_gate: self.touched_since_gate.clone(), + metrics: self.metrics.snapshot(), + } + } + + fn is_pristine_for_checkpoint_restore(&self) -> bool { + self.preconfirmed_branch.is_none() + && self.journal.is_empty() + && self.coverage_head.is_none() + && self.pending_resyncs.is_empty() + && self.health == CacheHealth::Healthy + && self.safe_head.is_none() + && self.finalized_head.is_none() + && self.tracked_roots.is_empty() + && self.last_gate_block.is_none() + && self.touched_since_gate.is_empty() + && self.metrics.snapshot() == CacheMetricsSnapshot::default() + } + + fn adopted_baseline_only(&self) -> Option { + let baseline = self.coverage_head?; + let journal_is_baseline_only = if self.config.journal_depth == 0 { + self.journal.is_empty() + } else { + self.journal.len() == 1 + && self.journal.front().is_some_and(|entry| { + entry.block == baseline + && entry.inputs.is_empty() + && entry.applied.is_empty() + && entry.handler_ids.is_empty() + && entry.resynced.is_empty() + && entry.rollback_diffs.is_empty() + }) + }; + (self.preconfirmed_branch.is_none() + && journal_is_baseline_only + && self.pending_resyncs.is_empty() + && self.health == CacheHealth::Healthy + && self.safe_head.is_none() + && self.finalized_head.is_none() + && self.tracked_roots.is_empty() + && self.last_gate_block.is_none() + && self.touched_since_gate.is_empty() + && self.metrics.snapshot() == CacheMetricsSnapshot::default()) + .then_some(baseline) + } + + fn restore_state(&mut self, state: ReactiveRuntimeState) { + self.journal = state.journal; + self.coverage_head = state.coverage_head; + self.pending_resyncs = state.pending_resyncs; + self.health = state.health; + self.safe_head = state.safe_head; + self.finalized_head = state.finalized_head; + self.freshness = state.freshness; + self.tracking = state.tracking; + self.tracked_roots = state.tracked_roots; + self.root_gate_cadence = state.root_gate_cadence; + self.last_gate_block = state.last_gate_block; + self.touched_since_gate = state.touched_since_gate; + self.metrics.restore(state.metrics); + } + + fn restore_transaction_state(&mut self, state: ReactiveRuntimeState) { + // Metrics describe lifetime observations, including rejected attempts, + // and are documented as monotonic. Roll back canonical/runtime state + // without erasing the failure signal that caused the transaction to + // abort. + let metrics = self.metrics.snapshot(); + self.restore_state(state); + self.metrics.restore(metrics); + } + + fn durable_checkpoint_bytes(&self) -> Result, ReactiveEngineError> { + let checkpoint = DurableRuntimeCheckpoint { + version: DURABLE_RUNTIME_CHECKPOINT_VERSION, + safe_head: self.safe_head, + finalized_head: self.finalized_head, + health: self.health, + pending_resyncs: self.pending_resyncs.clone(), + coverage_head: self.coverage_head, + journal: self + .journal + .iter() + .map(|entry| DurableBlockJournal { + block: entry.block, + handler_ids: entry.handler_ids.clone(), + rollback_diffs: entry.rollback_diffs.clone(), + }) + .collect(), + freshness: self.freshness.clone(), + tracking: self.tracking.clone(), + tracked_roots: self.tracked_roots.clone(), + root_gate_cadence: self.root_gate_cadence, + last_gate_block: self.last_gate_block, + touched_since_gate: self.touched_since_gate.clone(), + metrics: self.metrics.snapshot(), + }; + bincode::serialize(&checkpoint) + .map_err(|error| ReactiveEngineError::RuntimeCheckpoint(error.to_string())) + } + + fn plan_durable_checkpoint_restore( + &self, + bytes: &[u8], + expected_coverage: &BlockRef, + ) -> Result { + let mut cursor = std::io::Cursor::new(bytes); + let mut checkpoint: DurableRuntimeCheckpoint = bincode::DefaultOptions::new() + .with_fixint_encoding() + .with_limit(bytes.len() as u64) + .deserialize_from(&mut cursor) + .map_err(|error| { + ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(error.to_string()) + })?; + if cursor.position() != bytes.len() as u64 { + return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint( + "runtime checkpoint has trailing bytes".to_owned(), + )); + } + if checkpoint.version != DURABLE_RUNTIME_CHECKPOINT_VERSION { + return Err(ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint( + format!( + "unsupported runtime checkpoint version {}", + checkpoint.version + ), + )); + } + self.validate_durable_runtime_checkpoint(&checkpoint, expected_coverage)?; + + let retained = self.config.journal_depth.min(checkpoint.journal.len()); + let discard = checkpoint.journal.len() - retained; + checkpoint.journal.drain(..discard); + Ok(DurableRuntimeRestorePlan { + checkpoint: Some(checkpoint), + fallback_history: Vec::new(), + }) + } + + fn apply_durable_checkpoint_restore(&mut self, plan: DurableRuntimeRestorePlan) { + let Some(checkpoint) = plan.checkpoint else { + self.journal = plan + .fallback_history + .into_iter() + .map(|block| BlockJournal { + block, + inputs: Vec::new(), + applied: Vec::new(), + handler_ids: Vec::new(), + resynced: Vec::new(), + rollback_diffs: Vec::new(), + }) + .collect(); + return; + }; + self.safe_head = checkpoint.safe_head; + self.finalized_head = checkpoint.finalized_head; + self.health = checkpoint.health; + self.pending_resyncs = checkpoint.pending_resyncs; + self.coverage_head = checkpoint.coverage_head; + self.journal = checkpoint + .journal + .into_iter() + .map(|entry| BlockJournal { + block: entry.block, + inputs: Vec::new(), + applied: Vec::new(), + handler_ids: entry.handler_ids, + resynced: Vec::new(), + rollback_diffs: entry.rollback_diffs, + }) + .collect(); + self.freshness = checkpoint.freshness; + self.tracking = checkpoint.tracking; + self.tracked_roots = checkpoint.tracked_roots; + self.root_gate_cadence = checkpoint.root_gate_cadence; + self.last_gate_block = checkpoint.last_gate_block; + self.touched_since_gate = checkpoint.touched_since_gate; + self.metrics.restore(checkpoint.metrics); + } + + fn validate_durable_runtime_checkpoint( + &self, + checkpoint: &DurableRuntimeCheckpoint, + expected_coverage: &BlockRef, + ) -> Result<(), ReactiveCheckpointRestoreError> { + let invalid = + |message: String| ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(message); + let Some(coverage) = checkpoint.coverage_head.as_ref() else { + return Err(invalid( + "runtime checkpoint is missing its canonical coverage head".into(), + )); + }; + if !optional_block_refs_are_compatible(Some(coverage), Some(expected_coverage)) { + return Err(invalid(format!( + "runtime coverage {}:{:?} conflicts with checkpoint metadata {}:{:?}", + coverage.number, coverage.hash, expected_coverage.number, expected_coverage.hash + ))); + } + for (label, head) in [ + ("safe", checkpoint.safe_head.as_ref()), + ("finalized", checkpoint.finalized_head.as_ref()), + ] { + let Some(head) = head else { continue }; + if head.number > coverage.number + || (head.number == coverage.number && head.hash != coverage.hash) + { + return Err(invalid(format!( + "{label} head {}:{:?} lies beyond or conflicts with canonical coverage {}:{:?}", + head.number, head.hash, coverage.number, coverage.hash + ))); + } + if head.number.checked_add(1) == Some(coverage.number) + && coverage + .parent_hash + .is_some_and(|parent| parent != head.hash) + { + return Err(invalid(format!( + "canonical coverage does not descend from adjacent {label} head" + ))); + } + } + if let (Some(finalized), Some(safe)) = ( + checkpoint.finalized_head.as_ref(), + checkpoint.safe_head.as_ref(), + ) { + if finalized.number > safe.number + || (finalized.number == safe.number && finalized.hash != safe.hash) + { + return Err(invalid( + "finalized head is above or conflicts with the safe head".into(), + )); + } + if finalized.number.checked_add(1) == Some(safe.number) + && safe.parent_hash != Some(finalized.hash) + { + return Err(invalid( + "adjacent safe head does not descend from finalized head".into(), + )); + } + } + + let mut previous: Option<&DurableBlockJournal> = None; + for entry in &checkpoint.journal { + if entry.block.number > coverage.number + || (entry.block.number == coverage.number && entry.block.hash != coverage.hash) + { + return Err(invalid(format!( + "journal block {}:{:?} lies beyond or conflicts with canonical coverage", + entry.block.number, entry.block.hash + ))); + } + if let Some(previous) = previous { + if entry.block.number <= previous.block.number { + return Err(invalid( + "runtime journal block numbers are not strictly increasing".into(), + )); + } + if previous.block.number.checked_add(1) == Some(entry.block.number) + && entry.block.parent_hash.is_some() + && entry.block.parent_hash != Some(previous.block.hash) + { + return Err(invalid( + "adjacent runtime journal blocks are not parent-linked".into(), + )); + } + } + for (label, head) in [ + ("safe", checkpoint.safe_head.as_ref()), + ("finalized", checkpoint.finalized_head.as_ref()), + ] { + if let Some(head) = head + && head.number == entry.block.number + && !optional_block_refs_are_compatible(Some(head), Some(&entry.block)) + { + return Err(invalid(format!( + "{label} head conflicts with the retained journal at block {}", + head.number + ))); + } + } + let mut handler_ids = HashSet::new(); + if entry + .handler_ids + .iter() + .any(|handler_id| !handler_ids.insert(handler_id)) + { + return Err(invalid( + "runtime journal contains duplicate handler generation ids".into(), + )); + } + previous = Some(entry); + } + if let Some(tail) = checkpoint.journal.last() + && tail.block.number == coverage.number + && !optional_block_refs_are_compatible(Some(&tail.block), Some(coverage)) + { + return Err(invalid(format!( + "runtime journal tail conflicts with canonical coverage at block {}", + coverage.number + ))); + } + if let Some(tail) = checkpoint.journal.last() + && tail.block.number.checked_add(1) == Some(coverage.number) + && coverage + .parent_hash + .is_some_and(|parent_hash| parent_hash != tail.block.hash) + { + return Err(invalid(format!( + "canonical coverage does not descend from adjacent runtime journal tail at block {}", + tail.block.number + ))); + } + + if let Some(last_gate_block) = checkpoint.last_gate_block { + if last_gate_block > coverage.number { + return Err(invalid( + "root-gate cursor lies beyond canonical coverage".into(), + )); + } + } else if !checkpoint.tracked_roots.is_empty() { + return Err(invalid( + "root-gate baselines exist without a completed gate cursor".into(), + )); + } + for (address, baseline) in &checkpoint.tracked_roots { + let Some(policy) = checkpoint.tracking.get(address) else { + return Err(invalid( + "root-gate baseline has no corresponding tracking policy".into(), + )); + }; + if matches!(policy, TrackingPolicy::Slots { .. }) { + return Err(invalid( + "slot-only tracking cannot carry an account root baseline".into(), + )); + } + if baseline.last_block > coverage.number + || checkpoint + .last_gate_block + .is_some_and(|last_gate| baseline.last_block > last_gate) + { + return Err(invalid( + "root-gate baseline lies beyond the committed gate window".into(), + )); + } + } + Ok(()) + } + + /// Track `address` under `policy` for the per-block root gate (Phase-8 step 4). + /// + /// Tracking is strictly opt-in: a runtime with no tracked accounts runs the + /// gate as a no-op. Registering an account clears any baseline it held (a + /// policy change re-adopts on the next probe rather than diffing against a + /// baseline captured under the old policy). Each [`RootGateCadence`] + /// firing, the gate + /// probes tracked [`WholeAccount`](TrackingPolicy::WholeAccount) and + /// [`Scalars`](TrackingPolicy::Scalars) accounts' roots/fields via the + /// account-proof seam and, on a move no decoder covered, emits a + /// [`ReactiveReport::CoverageGap`] and schedules a + /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots) + /// accounts are never root-gated (spec Decision 3). + pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) { + self.tracking.insert(address, policy); + self.tracked_roots.remove(&address); + } + + /// Stop tracking `address`, dropping its policy and any adopted baseline. + /// + /// Returns `true` if the account was tracked. + pub fn untrack_account(&mut self, address: Address) -> bool { + self.tracked_roots.remove(&address); + self.tracking.remove(&address).is_some() + } + + /// Set how often the root gate probes tracked accounts (default: + /// [`RootGateCadence::default`] — every 16 canonical blocks; see the + /// [`RootGateCadence`] docs for why skipping blocks loses no detection). + /// + /// Reconfiguring resets the gate's window bookkeeping (the touched-address + /// accumulator and the last-fired block), so a stale window never leaks + /// into the new cadence: the next canonical block fires the gate. + pub fn set_root_gate_cadence(&mut self, cadence: RootGateCadence) { + self.root_gate_cadence = cadence; + self.last_gate_block = None; + self.touched_since_gate.clear(); + } + + /// The configured [`RootGateCadence`]. + pub fn root_gate_cadence(&self) -> RootGateCadence { + self.root_gate_cadence + } + + /// Enable freshness stamping of canonical event-derived writes (opt-in). + /// + /// Installs a [`FreshnessRegistry`] the runtime owns; while it is present, + /// applying a canonical handler storage-slot effect for a block `N` stamps the + /// touched `(address, slot)` as + /// [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough)`(N)`. + /// The slot is therefore not volatile *at* `N` (event-maintained, no need to + /// re-verify) but ages to volatile once the clock passes `N`. + /// + /// Idempotent: if a registry is already installed it is left untouched, so an + /// existing registry (and any stamps it holds) is never clobbered. + pub fn enable_freshness_stamping(&mut self) { + if self.freshness.is_none() { + self.freshness = Some(FreshnessRegistry::new()); + } + } + + /// Borrow the runtime's freshness registry, if stamping was enabled. + /// + /// Returns `None` unless + /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called. + pub fn freshness(&self) -> Option<&FreshnessRegistry> { + self.freshness.as_ref() + } + + /// Mutably borrow the runtime's freshness registry, if stamping was enabled. + /// + /// Returns `None` unless + /// [`enable_freshness_stamping`](Self::enable_freshness_stamping) was called. + pub fn freshness_mut(&mut self) -> Option<&mut FreshnessRegistry> { + self.freshness.as_mut() + } + + /// Return the current queryable [`CacheHealth`] of the runtime. + pub fn health(&self) -> CacheHealth { + self.health + } + + /// Return a point-in-time snapshot of the runtime's observability counters. + pub fn metrics(&self) -> CacheMetricsSnapshot { + self.metrics.snapshot() + } + + /// Complete the caller-driven self-heal by returning health to + /// [`CacheHealth::Healthy`]. + /// + /// A trust-loss event (a reorg deeper than the journal, or a detected missed + /// block range) escalates health toward [`CacheHealth::Unhealthy`] as a + /// "stop until rebuilt" signal that the caller must act on. Once the caller + /// has resynced or rebuilt the affected state, it invokes this to clear the + /// signal. It does not emit a [`ReactiveReport::Health`] report, since it is + /// called outside an ingest cycle. + pub fn reset_health(&mut self) { + self.health = CacheHealth::Healthy; + } + + /// Escalate health one rung up the trust-loss ladder for a trust-loss event + /// observed at `block`, returning a [`ReactiveReport::Health`] report when the + /// state actually changes. + /// + /// The ladder is: + /// - [`Healthy`](CacheHealth::Healthy) -> [`Degraded`](CacheHealth::Degraded) + /// - [`Degraded`](CacheHealth::Degraded) -> [`Unhealthy`](CacheHealth::Unhealthy) + /// - [`Unhealthy`](CacheHealth::Unhealthy) -> no change (`None`) + /// + /// A first event degrades; a second escalates to the terminal + /// [`Unhealthy`](CacheHealth::Unhealthy) stop signal. This is shared by both + /// trust-loss paths (deep reorg beyond the journal and missed-range + /// detection) so mixed event types climb the same ladder. + fn escalate_trust(&mut self, block: u64) -> Option>> { + let to = match self.health { + CacheHealth::Healthy => CacheHealth::Degraded { since_block: block }, + CacheHealth::Degraded { .. } => CacheHealth::Unhealthy { since_block: block }, + CacheHealth::Unhealthy { .. } => return None, + }; + self.transition_health(to, Some(block)) + } + + /// Transition health to `to`, returning a [`ReactiveReport::Health`] report + /// when the state actually changes. + /// + /// The returned report must be threaded into the ingest cycle's dispatched + /// reports so it reaches hooks and appears in + /// [`ReactiveBatchReport::reports`]. Returns `None` when `to` equals the + /// current state (no transition, no report). + fn transition_health( + &mut self, + to: CacheHealth, + block: Option, + ) -> Option>> { + if to == self.health { + return None; + } + let from = self.health; + self.health = to; + Some(Arc::new(ReactiveReport::Health(HealthReport { + from, + to, + block, + _network: PhantomData, + }))) + } + + /// Register a handler. + /// + /// # Errors + /// + /// Returns [`RegisterError::DuplicateHandler`] when the id is already + /// registered. + pub fn register_handler( + &mut self, + handler: Arc>, + ) -> Result<(), RegisterError> { + self.registry.register_handler(handler) + } + + /// Remove one handler from the runtime registry without resetting runtime state. + /// + /// This delegates to [`ReactiveRegistry::unregister_handler`] only. It does + /// not clear the reorg journal, health, metrics, hooks, pending resyncs, + /// tracking policy, freshness registry, or root-gate baselines, and it does + /// not purge [`EvmCache`] state. Callers that want cache eviction must issue + /// explicit `StateUpdate::purge` updates or use cache purge APIs separately. + pub fn unregister_handler(&mut self, id: &HandlerId) -> Option>> { + self.registry.unregister_handler(id) + } + + /// Return true when the runtime has a registered handler with `id`. + pub fn contains_handler(&self, id: &HandlerId) -> bool { + self.registry.contains_handler(id) + } + + /// Ids of all registered handlers, in registration (= routing) order. + pub fn handler_ids(&self) -> Vec { + self.registry.handler_ids() + } + + /// Borrow the interests owned by one registered handler. + pub fn handler_interests(&self, id: &HandlerId) -> Option<&[ReactiveInterest]> { + self.registry.handler_interests(id) + } + + /// The most recently journaled canonical block, if any. + /// + /// This is the runtime's current chain position: the canonical block most + /// recently recorded by ingestion. Reorged blocks are dropped from the + /// journal during recovery, so a rolled-back head does not linger here. + /// [`ReactiveEngine::register_handler`] uses it as the default backfill + /// anchor for handlers registered mid-lifecycle. An ordered barrier may + /// advance this coverage position across an empty event range. `None` until + /// the first canonical input or barrier is accepted. + pub fn last_canonical_block(&self) -> Option { + self.coverage_head + } + + /// Adopt an exact RPC snapshot block as this runtime's canonical starting + /// position without applying effects or dispatching reports. + /// + /// Handlers, hooks, tracking policy, and freshness configuration may be + /// installed before adoption, but no chain input, finality, resync, + /// root-gate observation, or health transition may have occurred. An exact + /// repeat is idempotent; a different repeat and any active runtime fail + /// closed. Prefer [`ReactiveEngine::adopt_canonical_baseline`] when a cache + /// and subscriber are available so chain identity and the cache's exact + /// hash pin are validated too. + /// + /// # Errors + /// + /// Returns [`ReactiveBaselineError::ActiveRuntime`] after any runtime + /// activity, or [`ReactiveBaselineError::ConflictingBaseline`] when a + /// different baseline has already been adopted. + pub fn adopt_canonical_baseline( + &mut self, + baseline: BlockRef, + ) -> Result<(), ReactiveBaselineError> { + self.validate_canonical_baseline_adoption(baseline)?; + if self.adopted_baseline_only().is_some() { + return Ok(()); + } + + self.coverage_head = Some(baseline); + if self.config.journal_depth > 0 { + self.journal.push_back(BlockJournal { + block: baseline, + inputs: Vec::new(), + applied: Vec::new(), + handler_ids: Vec::new(), + resynced: Vec::new(), + rollback_diffs: Vec::new(), + }); + } + Ok(()) + } + + fn validate_canonical_baseline_adoption( + &self, + baseline: BlockRef, + ) -> Result<(), ReactiveBaselineError> { + if let Some(existing) = self.adopted_baseline_only() { + return if existing == baseline { + Ok(()) + } else { + Err(ReactiveBaselineError::ConflictingBaseline { + existing_number: existing.number, + existing_hash: existing.hash, + requested_number: baseline.number, + requested_hash: baseline.hash, + }) + }; + } + if !self.is_pristine_for_checkpoint_restore() { + return Err(ReactiveBaselineError::ActiveRuntime); + } + Ok(()) + } + + /// Most recent safe head explicitly reported by the event source. + pub const fn safe_head(&self) -> Option<&BlockRef> { + self.safe_head.as_ref() + } + + /// Most recent finalized head explicitly reported by the event source. + pub const fn finalized_head(&self) -> Option<&BlockRef> { + self.finalized_head.as_ref() + } + + /// Return whether the retained reorg journal still contains an applied + /// record for `handler_id`. + /// + /// The record is retained even when the handler emitted only resync work, + /// so an owner can keep an explicit cache-eviction fence active for exactly /// as long as a later rollback could restore effects from that handler /// generation. This query is bounded by [`ReactiveConfig::journal_depth`]. pub fn has_journaled_handler_effects(&self, handler_id: &HandlerId) -> bool { - self.journal.iter().any(|entry| { - entry - .applied + self.journal + .iter() + .any(|entry| entry.handler_ids.contains(handler_id)) + } + + /// Return the distinct handler generations represented in the retained + /// reorg journal. + /// + /// This scans the bounded journal once, allowing a lifecycle owner to age a + /// large set of cache-eviction fences without rescanning the journal for + /// every handler. + pub fn journaled_handler_ids(&self) -> HashSet { + self.journal + .iter() + .flat_map(|entry| entry.handler_ids.iter().cloned()) + .collect() + } + + /// Queued resync requests: surfaced by handlers but not yet executed by an + /// [`ingest_batch_with_resync`](Self::ingest_batch_with_resync) pass. + /// + /// Callers driving resync execution themselves (plain + /// [`ingest_batch`](Self::ingest_batch) loops) can read the ledger here; + /// reorg recovery cancels entries whose pinned blocks were dropped, and + /// [`cancel_pending_resync`](Self::cancel_pending_resync) drops exact + /// generation-owned work, while + /// [`cancel_pending_resyncs`](Self::cancel_pending_resyncs) drops entries + /// for exclusively torn-down accounts. + pub fn pending_resyncs(&self) -> &[ResyncRequest] { + &self.pending_resyncs + } + + /// Cancel every queued request with the exact logical `id`. + /// + /// Unlike [`cancel_pending_resyncs`](Self::cancel_pending_resyncs), this + /// removes whole requests and never touches other work merely because it + /// targets the same account. It is therefore the safe primitive for + /// generation-scoped owner teardown when the caller maintains an + /// owner-to-[`ResyncId`] index. Requests already returned to the caller in + /// an earlier batch report cannot be recalled. + pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec { + self.cancel_pending_resyncs_by_id(std::slice::from_ref(id)) + } + + /// Cancel queued requests whose logical ids occur in `ids` in one queue pass. + /// + /// Duplicate and unknown ids are harmless. Cancelled requests retain their + /// pending-queue order, independent of caller id order. This is the batch + /// teardown primitive for owners that can have many pending repairs; it + /// avoids rescanning the complete pending queue once per owned id. + pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec { + if ids.is_empty() { + return Vec::new(); + } + let ids: HashSet<&ResyncId> = ids.iter().collect(); + let mut cancelled = Vec::new(); + self.pending_resyncs.retain(|request| { + if ids.contains(&request.id) { + cancelled.push(request.clone()); + false + } else { + true + } + }); + cancelled + } + + /// Cancel queued resync work that targets `address`, returning the + /// cancelled portions. + /// + /// Every pending [`ResyncRequest`] target referencing `address` is removed; + /// a request reduced to zero targets is dropped entirely, while + /// mixed-target requests keep their other accounts queued. Each returned + /// request mirrors the original id/reason/block/priority and carries only + /// the targets that were cancelled. + /// + /// This is appropriate only when the caller owns the complete account. For + /// a pool sharing a vault or emitter with other owners, cancel its exact + /// request IDs through + /// [`cancel_pending_resync`](Self::cancel_pending_resync) instead. It cannot + /// recall requests already returned to the caller in earlier batch reports. + pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec { + let mut cancelled = Vec::new(); + self.pending_resyncs.retain_mut(|request| { + let (matching, remaining): (Vec<_>, Vec<_>) = request + .targets + .drain(..) + .partition(|target| resync_target_address(target) == address); + request.targets = remaining; + if !matching.is_empty() { + cancelled.push(ResyncRequest { + id: request.id.clone(), + reason: request.reason.clone(), + block: request.block.clone(), + targets: matching, + priority: request.priority, + }); + } + !request.targets.is_empty() + }); + cancelled + } + + /// Register a hook. + /// + /// # Errors + /// + /// This implementation is currently infallible; the `Result` preserves the + /// registration contract for future hook validation. + pub fn register_hook(&mut self, hook: Arc>) -> Result<(), RegisterError> { + self.hooks.push(hook); + Ok(()) + } + + /// Return all registered interests in handler registration order. + pub fn interests(&self) -> Vec> { + self.registry.interests() + } + + /// Ingest a batch, apply valid direct state effects, and dispatch reports. + /// + /// The commit is atomic on `Err`: cache state and canonical runtime state are + /// restored before the error returns, and hooks see no reports. Monotonic + /// observability counters still retain rejected-attempt signals. + /// The current rollback guard snapshots complete mutable cache state once per + /// batch, so callers should preserve transport batching rather than splitting + /// one delivery into many one-record calls. + /// + /// # Errors + /// + /// Returns [`ReactiveError`] when records or controls are invalid, canonical + /// continuity cannot be proven, a handler rejects input, or an effect cannot + /// be applied. Cache and canonical runtime state are restored before return. + pub fn ingest_batch( + &mut self, + cache: &mut EvmCache, + batch: ReactiveInputBatch, + ) -> Result, ReactiveError> { + let preconfirmation = batch_preconfirmation(&batch)?; + if let Some(flashblock) = preconfirmation.as_ref() { + self.prepare_preconfirmed_branch(cache, flashblock)?; + } else { + self.discard_preconfirmed_branch(cache); + } + let cache_state = EvmCacheStateSnapshot::capture(cache); + let runtime_state = self.checkpoint_state(); + let batch_report = match self.ingest_batch_direct(cache, batch) { + Ok(report) => report, + Err(error) => { + cache_state.restore(cache); + self.restore_transaction_state(runtime_state); + return Err(error); + } + }; + if let Some(flashblock) = preconfirmation { + self.restore_transaction_state(runtime_state); + if let Some(branch) = self.preconfirmed_branch.as_mut() { + branch.flashblock = flashblock; + } + } + self.dispatch_reports(&batch_report.reports); + let _ = &self.config; + Ok(batch_report) + } + + /// Ingest a batch, then execute surfaced storage resync requests. + /// + /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for + /// direct handler effects, then runs a synchronous resync phase over the + /// collected [`ResyncRequest`]s. Storage targets are fetched through + /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful + /// values are applied as [`StateUpdate::slot`] updates through + /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported + /// in [`ResyncReport::failed`]. It does not start subscribers, background + /// workers, or network transport. + /// + /// # Errors + /// + /// Returns [`ReactiveError`] for the same validation, continuity, handler, + /// or direct-effect failures as [`ingest_batch`](Self::ingest_batch). Failed + /// resync targets are reported in the successful batch report instead. + pub fn ingest_batch_with_resync( + &mut self, + cache: &mut EvmCache, + batch: ReactiveInputBatch, + ) -> Result, ReactiveError> { + let preconfirmation = batch_preconfirmation(&batch)?; + if let Some(flashblock) = preconfirmation.as_ref() { + self.prepare_preconfirmed_branch(cache, flashblock)?; + } else { + self.discard_preconfirmed_branch(cache); + } + let cache_state = EvmCacheStateSnapshot::capture(cache); + let runtime_state = self.checkpoint_state(); + let batch_report = match self.ingest_batch_with_resync_direct(cache, batch) { + Ok(report) => report, + Err(error) => { + cache_state.restore(cache); + self.restore_transaction_state(runtime_state); + return Err(error); + } + }; + + if let Some(flashblock) = preconfirmation { + self.restore_transaction_state(runtime_state); + if let Some(branch) = self.preconfirmed_branch.as_mut() { + branch.flashblock = flashblock; + } + } + + self.dispatch_reports(&batch_report.reports); + let _ = &self.config; + Ok(batch_report) + } + + /// Active speculative Flashblock snapshot, when the cache currently + /// includes pre-confirmed effects. + pub fn active_preconfirmation(&self) -> Option<&FlashblockRef> { + self.preconfirmed_branch + .as_ref() + .map(|branch| &branch.flashblock) + } + + /// Restore the cache to its canonical state and discard any speculative + /// Flashblock effects. + pub fn discard_preconfirmation(&mut self, cache: &mut EvmCache) { + self.discard_preconfirmed_branch(cache); + } + + fn discard_preconfirmed_branch(&mut self, cache: &mut EvmCache) { + if let Some(branch) = self.preconfirmed_branch.take() { + branch.canonical_cache.restore(cache); + } + } + + fn prepare_preconfirmed_branch( + &mut self, + cache: &mut EvmCache, + incoming: &FlashblockRef, + ) -> Result<(), ReactiveError> { + if let Some(active) = self.preconfirmed_branch.as_ref() + && active.flashblock.same_payload(incoming) + { + if let (Some(active_index), Some(incoming_index)) = + (active.flashblock.index, incoming.index) + && incoming_index < active_index + { + return Err(ReactiveError::InvalidInputRecord { + message: format!( + "Flashblock index regressed from {active_index} to {incoming_index}" + ), + }); + } + if active.flashblock.index == incoming.index + && active.flashblock.block_hash != incoming.block_hash + { + return Err(ReactiveError::InvalidInputRecord { + message: + "same Flashblock payload/index carried conflicting partial block hashes" + .into(), + }); + } + return Ok(()); + } + + self.discard_preconfirmed_branch(cache); + self.preconfirmed_branch = Some(PreconfirmedBranch { + flashblock: incoming.clone(), + canonical_cache: EvmCacheStateSnapshot::capture(cache), + }); + Ok(()) + } + + fn ingest_batch_with_resync_direct( + &mut self, + cache: &mut EvmCache, + batch: ReactiveInputBatch, + ) -> Result, ReactiveError> { + let mut batch_report = self.ingest_batch_direct(cache, batch)?; + if !batch_report.resyncs.is_empty() { + let resync_report = execute_resync_requests(cache, &batch_report.resyncs); + // Count unique logical requests: several handlers may emit the same + // ResyncId in one batch, and duplicates fan out per-origin in the + // report but are one unit of resync work for the metric. + let unique_requests = resync_report + .requested + .iter() + .map(|request| &request.id) + .collect::>() + .len(); + self.metrics + .resync_requests + .fetch_add(unique_requests as u64, Ordering::Relaxed); + self.metrics + .resync_failures + .fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed); + self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id)); + self.record_journal_resync(&resync_report); + batch_report + .reports + .push(Arc::new(ReactiveReport::Resynced(resync_report))); + } + Ok(batch_report) + } + + fn ingest_batch_direct( + &mut self, + cache: &mut EvmCache, + batch: ReactiveInputBatch, + ) -> Result, ReactiveError> { + let (records, chain_controls, batch_chain_id) = batch.into_runtime_parts(); + if let Some(chain_id) = batch_chain_id + && chain_id != cache.chain_id() + { + return Err(ReactiveError::InvalidInputRecord { + message: format!( + "batch chain id {chain_id} does not match cache chain id {}", + cache.chain_id() + ), + }); + } + if !chain_controls.is_empty() && batch_chain_id.is_none() { + return Err(ReactiveError::InvalidChainControl { + message: "chain-control batches require an authoritative batch chain id".into(), + }); + } + for (record, _, _) in &records { + record.validated_identity()?; + if let Some(chain_id) = record.context.chain_id + && chain_id != cache.chain_id() + { + return Err(ReactiveError::InvalidInputRecord { + message: format!( + "input chain id {chain_id} does not match cache chain id {}", + cache.chain_id() + ), + }); + } + } + let records = sort_scoped_records(dedupe_scoped_records(records)?); + + let mut batch_report = ReactiveBatchReport::default(); + let mut reports_to_dispatch = Vec::new(); + let control_split = validate_control_phase_order(&chain_controls)?; + let (pre_record_controls, post_record_controls) = chain_controls.split_at(control_split); + let pre_record_state = + self.validate_ingest_sequence(pre_record_controls, post_record_controls, &records)?; + self.validate_owner_catchup_against_journal(&pre_record_state, &records)?; + let mut batch_dropped = BatchDroppedCanonical::default(); + for control in pre_record_controls { + if let ChainControl::Reorg { + common_ancestor, + old_tip, + .. + } = control + { + batch_dropped.record_explicit(common_ancestor, old_tip); + let drained = self + .journal + .iter() + .filter(|entry| entry.block.number > common_ancestor.number) + .map(|entry| entry.block) + .collect::>(); + batch_dropped.record_drained(&drained); + } + } + let certified_progress_through = post_record_controls + .iter() + .filter_map(canonical_coverage_control_block) + .map(|block| block.number) + .max(); + for control in pre_record_controls.iter().cloned() { + self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch); + } + // Phase-8 step 4: accumulate the addresses a decoder actually wrote this + // batch (union of applied `StateDiff` addresses) and the batch's canonical + // block number, so the per-block root gate can run once after the record + // loop with the full touched set. + let mut touched_addrs: HashSet
= HashSet::new(); + let mut canonical_batch_block: Option = None; + + for (record, audience, delivery_scope) in records { + let raw_canonical_block = canonical_record_block(&record).copied(); + let canonical_block = raw_canonical_block.map(|block| { + pre_record_state + .resolved_canonical_blocks + .get(&(block.number, block.hash)) + .copied() + .unwrap_or(block) + }); + let input_ref = record.input_ref(); + reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport { + input_ref, + context: record.context.clone(), + provider: record.provider.clone(), + _network: PhantomData, + }))); + + let recovered_reorg = if delivery_scope.advances_canonical_state() { + if let Some(block) = canonical_block.as_ref() { + let gap_is_certified = delivery_scope == DeliveryScope::CanonicalProgress + && certified_progress_through + .is_some_and(|through| block.number <= through); + let parentless_replacement_is_proven = raw_canonical_block.is_some_and(|raw| { + raw.parent_hash.is_none() + && batch_dropped.covers_implicit_number(raw.number) + }); + self.recover_for_canonical_input( + cache, + block, + gap_is_certified, + parentless_replacement_is_proven, + &mut reports_to_dispatch, + ) + } else { + None + } + } else { + None + }; + let recovered_reorg_for_input = recovered_reorg.is_some(); + if let Some(reorg_report) = recovered_reorg { + self.metrics + .reorgs_recovered + .fetch_add(1, Ordering::Relaxed); + remove_canceled_resyncs_from_batch( + &mut batch_report.resyncs, + &reorg_report.canceled_resyncs, + ); + reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report))); + } + + // Removed/reorged records are lifecycle signals, never handler + // data. Canonical scopes may roll back state; owner-only catch-up + // scopes deliberately cannot, but both must suppress ordinary + // decoding even when the referenced block is unknown, aged out of + // the journal, or has already been removed once. + if reorg_signal_block(&record).is_some() { + if delivery_scope.advances_canonical_state() + && let Some(reorg_report) = self.recover_for_reorged_input( + cache, + &record, + &mut batch_dropped, + &mut reports_to_dispatch, + ) + { + self.metrics + .reorgs_recovered + .fetch_add(1, Ordering::Relaxed); + remove_canceled_resyncs_from_batch( + &mut batch_report.resyncs, + &reorg_report.canceled_resyncs, + ); + reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report))); + } + continue; + } + + // Preflight validates owner history against the journal state at + // batch entry. A canonical record earlier in this same transaction + // may legitimately replace and drain that block, so close the + // resulting TOCTOU window immediately before any owner handler can + // mutate the cache. The outer transaction guard restores every + // earlier record in the batch on failure. + if delivery_scope == DeliveryScope::OwnerCatchup { + self.validate_owner_catchup_record_against_current_journal(&record)?; + } + + if delivery_scope.advances_canonical_state() + && let Some(block) = canonical_block.as_ref() + { + // Phase-8 step 4: remember the batch's canonical block (the last + // canonical record wins) so the root gate probes at that height. + canonical_batch_block = Some(block.number); + self.record_journal_input(block, input_ref); + } + + // Keep every lazy provider read pinned to the exact event block + // before handlers run. A full header installs the complete EVM env; + // compact log-only progress installs NUMBER/timestamp and clears + // unknown header-only fields. A later record for the same retained + // canonical block can preserve an already-installed full env. + if delivery_scope.advances_canonical_state() + && let Some(block) = canonical_block.as_ref() + { + match advance_block_for_canonical_record(cache, &record) { + Some(Ok(())) => { + cache.advance_compact_block(block.number, block.hash, block.timestamp, true) + } + Some(Err(err)) => { + cache.advance_compact_block( + block.number, + block.hash, + block.timestamp, + false, + ); + reports_to_dispatch.push(Arc::new(ReactiveReport::Error( + ReactiveErrorReport { + input_ref: Some(input_ref), + message: err.to_string(), + _network: PhantomData, + }, + ))); + } + None => cache.advance_compact_block( + block.number, + block.hash, + block.timestamp, + !recovered_reorg_for_input, + ), + } + } + + let executions = self.execute_handlers(cache, &record, input_ref, &audience)?; + if executions.is_empty() { + continue; + } + + reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport { + input_ref, + handler_ids: executions + .iter() + .map(|execution| execution.handler_id.clone()) + .collect(), + _network: PhantomData, + }))); + + detect_conflicts(input_ref, &executions)?; + + // Phase-8 step 3: canonical block number for freshness stamping. + // Copied out as a plain `u64` (dropping the borrow of `record`) so it + // can be used while `self.freshness_mut()` mutably borrows `self` + // inside the execution loop. `None` for pending/removed/reorged + // records — those never stamp canonical freshness. + let canonical_block_number = delivery_scope + .advances_canonical_state() + .then_some(canonical_block) + .flatten() + .map(|block| block.number); + + for execution in executions { + let diff = if execution.state_updates.is_empty() { + StateDiff::default() + } else { + cache.apply_updates(&execution.state_updates) + }; + + batch_report + .resyncs + .extend(execution.resyncs.iter().cloned()); + self.pending_resyncs + .extend(execution.resyncs.iter().cloned()); + batch_report + .speculative + .extend(execution.speculative.iter().cloned()); + + let applied = AppliedReport { + input_ref, + handler_id: execution.handler_id, + quality: execution.quality, + tags: execution.tags, + diff, + state_updates: execution.state_updates, + invalidations: execution.invalidations, + resyncs: execution.resyncs, + speculative: execution.speculative, + hook_signals: execution.hook_signals, + _network: PhantomData, + }; + // Phase-8 step 3 (opt-in): stamp every touched `(address, slot)` + // from this canonical handler write as `ValidThrough(N)`, so an + // event-maintained slot stops being re-verified until the clock + // passes its write block. Read the changed slots straight off + // `applied.diff` (which borrows the local, not `self`) and stamp + // via `self.freshness`, done before `applied` is moved into the + // journal/batch below. Only genuinely-changed slots appear here, + // since a no-op re-write records no `SlotChange`. + if let (Some(number), Some(registry)) = + (canonical_block_number, self.freshness.as_mut()) + { + for change in &applied.diff.slots { + registry.valid_through_slot(change.address, change.slot, number); + } + } + + // Phase-8 step 4: record every address this decoder actually wrote + // (or attempted to write) so the root gate can tell a + // decoder-covered root move from an uncovered coverage gap. Fold in + // the full `StateDiff` address footprint — real changes + // (`slots`/`accounts`/`purged`) and cold-skipped attempts alike, so + // a decoder that tried to write a cold slot still counts as + // covering the account. + if delivery_scope.advances_canonical_state() { + collect_diff_addresses(&applied.diff, &mut touched_addrs); + } + + let report = Arc::new(ReactiveReport::Applied(applied.clone())); + reports_to_dispatch.push(report); + if let Some(block) = canonical_block.as_ref() { + if delivery_scope.advances_canonical_state() { + self.record_journal_applied(block, applied.clone()); + } else { + self.record_journal_applied_if_present(block, applied.clone()); + } + } + batch_report.applied.push(applied); + } + } + + // Coverage/finality controls certify the records that precede them. + // Applying them here also leaves the live cache pinned to a certified + // zero-event tail rather than the last block that happened to emit a + // matching log. Reorg controls were applied before the record loop. + for control in post_record_controls.iter().cloned() { + if let Some(block) = canonical_coverage_control_block(&control) { + canonical_batch_block = Some( + canonical_batch_block.map_or(block.number, |current| current.max(block.number)), + ); + } + self.apply_chain_control(cache, control, &mut batch_report, &mut reports_to_dispatch); + } + + // Phase-8 step 4 + §6.2 cadence: accumulate this batch's touched + // addresses (after all handler effects, so the set is complete), then + // fire the root gate only on cadence boundaries. The gate diffs + // against persisted baselines, so skipped blocks lose no detection — + // but the touched set must be the union since the last firing, or a + // decoder-covered write in a skipped block would false-positive as a + // CoverageGap. Fired resyncs surface in `batch_report.resyncs` (so + // callers see them and `ingest_batch_with_resync` executes them) and + // coverage reports go into the dispatched reports. + if self.root_gate_runnable(cache) { + self.touched_since_gate + .extend(touched_addrs.iter().copied()); + if self.root_gate_due(canonical_batch_block) { + let accumulated = std::mem::take(&mut self.touched_since_gate); + self.run_root_gate( + cache, + canonical_batch_block, + &accumulated, + &mut batch_report.resyncs, + &mut reports_to_dispatch, + ); + self.last_gate_block = canonical_batch_block; + } + } else { + // A gate that cannot run (disabled, nothing root-gated, or no + // proof fetcher) must not grow the accumulator unboundedly. + // Dropping it is safe: without a runnable gate no baselines exist + // (a fetcher cannot be uninstalled, and untracking drops the + // baseline), so there is nothing a lost touched set could falsely + // gap against later. + self.touched_since_gate.clear(); + } + + batch_report.reports = reports_to_dispatch; + Ok(batch_report) + } + + /// Prove that every owner-only historical effect can be attached to an + /// compatible retained canonical journal entry before any chain control or + /// handler mutation is applied. Number/hash are exact. Parent/timestamp are + /// optional enrichment, but two present values must agree; this matches the + /// [`BlockRef`] compatibility rule used for cross-source deduplication. + /// + /// Owner catch-up deliberately does not advance canonical coverage. Its + /// effects are appended to the already-existing journal entry so a later + /// reorg can roll them back with the rest of that block. Accepting a block + /// outside the journal would make the cache mutation irreversible. A reorg + /// control in the same batch also invalidates entries above its ancestor, + /// so those entries are rejected even though they still exist at this + /// preflight point. + fn validate_owner_catchup_against_journal( + &self, + control_state: &ChainControlState, + records: &[(ReactiveInputRecord, DeliveryAudience, DeliveryScope)], + ) -> Result<(), ReactiveError> { + for (record, _, delivery_scope) in records { + if *delivery_scope != DeliveryScope::OwnerCatchup { + continue; + } + // Removed/reorged inputs are lifecycle signals only. Owner catch-up + // cannot make them canonical and the record loop deliberately skips + // handler execution, so there is no effect that needs attaching to + // a rollback journal entry. + if reorg_signal_block(record).is_some() { + continue; + } + let context_block = canonical_record_block(record).ok_or_else(|| { + ReactiveError::InvalidChainControl { + message: "owner catch-up input has no canonical block identity".into(), + } + })?; + let block = resolve_record_block_payload_metadata(record, *context_block)?; + let invalidated_by_control = control_state + .journal_invalidated_from + .is_some_and(|from| block.number >= from); + let rollbackable = !invalidated_by_control + && self.journal.iter().any(|entry| { + optional_block_refs_are_compatible(Some(&entry.block), Some(&block)) + }); + if !rollbackable { + return Err(ReactiveError::OwnerCatchupOutsideJournal { + number: block.number, + hash: block.hash, + }); + } + } + Ok(()) + } + + fn validate_owner_catchup_record_against_current_journal( + &self, + record: &ReactiveInputRecord, + ) -> Result<(), ReactiveError> { + let context_block = + canonical_record_block(record).ok_or_else(|| ReactiveError::InvalidChainControl { + message: "owner catch-up input has no canonical block identity".into(), + })?; + let block = resolve_record_block_payload_metadata(record, *context_block)?; + if self + .journal + .iter() + .any(|entry| optional_block_refs_are_compatible(Some(&entry.block), Some(&block))) + { + return Ok(()); + } + Err(ReactiveError::OwnerCatchupOutsideJournal { + number: block.number, + hash: block.hash, + }) + } + + /// Whether the root gate could produce any signal at all: some tracked + /// account is root-gated (`Slots` never is) and a proof fetcher exists. + /// When this is false the touched accumulator is dropped rather than + /// grown (see the ingest call site for why that is safe). + fn root_gate_runnable(&self, cache: &EvmCache) -> bool { + if matches!(self.root_gate_cadence, RootGateCadence::Disabled) { + return false; + } + let has_gated_targets = self + .tracking + .values() + .any(|policy| !matches!(policy, TrackingPolicy::Slots { .. })); + has_gated_targets && cache.account_proof_fetcher().is_some() + } + + /// Whether the root gate is due at this batch's canonical block (§6.2): + /// the first canonical block ever seen always fires (baseline adoption + /// must not wait a full window), then at most once every `n` blocks. + fn root_gate_due(&self, canonical_block: Option) -> bool { + let Some(block) = canonical_block else { + return false; + }; + match self.root_gate_cadence { + RootGateCadence::Disabled => false, + RootGateCadence::EveryNBlocks(n) => match self.last_gate_block { + None => true, + Some(last) => block >= last.saturating_add(n.get()), + }, + } + } + + /// The `storageHash` root gate (Phase-8 step 4), fired per + /// [`RootGateCadence`] window (§6.2). + /// + /// Runs at the firing batch's canonical block, with `touched` carrying the + /// union of decoder-touched addresses since the previous firing. For each tracked + /// [`WholeAccount`](TrackingPolicy::WholeAccount) / [`Scalars`](TrackingPolicy::Scalars) + /// account, probe the root (and account fields) via the account-proof seam and + /// apply the spec §4 table: + /// + /// - No baseline yet ⇒ **adopt** (no gap, no resync — adoption is not a gap). + /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root unchanged ⇒ nothing. + /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∈ touched` + /// ⇒ a decoder covered it; re-adopt, no gap. + /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∉ touched` + /// ⇒ emit [`ReactiveReport::CoverageGap`], count it, schedule a + /// [`ResyncReason::RootMoved`] account resync, re-adopt. + /// - [`Scalars`](TrackingPolicy::Scalars) ⇒ compare balance/nonce/code-hash to + /// the baseline (native field changes never move the storage root); on a move + /// with `addr ∉ touched`, schedule a [`ResyncReason::RootMoved`] account + /// resync for the changed fields and re-adopt. + /// + /// No-op when the tracking registry is empty, when the batch has no canonical + /// block, or when the cache has no account-proof fetcher installed. + /// [`Slots`](TrackingPolicy::Slots) accounts are never root-gated (spec + /// Decision 3). + fn run_root_gate( + &mut self, + cache: &EvmCache, + canonical_block: Option, + touched: &HashSet
, + resyncs: &mut Vec, + reports: &mut Vec>>, + ) { + if self.tracking.is_empty() { + return; + } + let Some(block) = canonical_block else { + return; + }; + let Some(fetcher) = cache.account_proof_fetcher().cloned() else { + return; + }; + + // Collect the root-gated targets (Slots opts out) in a stable order so a + // single-block sequence of resyncs/reports is deterministic. + let mut targets: Vec<(Address, bool)> = self + .tracking + .iter() + .filter_map(|(address, policy)| match policy { + TrackingPolicy::Slots { .. } => None, + TrackingPolicy::WholeAccount => Some((*address, true)), + TrackingPolicy::Scalars => Some((*address, false)), + }) + .collect(); + if targets.is_empty() { + return; + } + targets.sort_by_key(|(address, _)| *address); + + let block_id = BlockId::number(block); + // ONE seam invocation carries every root-gated target (root-only + // probes: no storage keys needed). eth_getProof is single-address at + // the RPC level, so batching here lets the fetcher fan the requests + // out concurrently instead of paying N sequential round trips. + let mut probes: HashMap> = (fetcher)( + targets + .iter() + .map(|&(address, _)| (address, vec![])) + .collect(), + block_id, + ) + .into_iter() + .collect(); + for (address, whole_account) in targets { + let Some(Ok(proof)) = probes.remove(&address) else { + // A failed/omitted probe carries no signal; leave the baseline + // untouched and try again next block. + continue; + }; + + let baseline = self.tracked_roots.get(&address).cloned(); + let Some(baseline) = baseline else { + // First observation: adopt the baseline. Not a coverage gap. + self.adopt_root(address, block, &proof); + continue; + }; + + // A stale probe (a batch whose canonical block is not newer than the + // last one we baselined this account against) carries no forward + // signal: skip it rather than diff against — or clobber — a newer + // baseline. + if block <= baseline.last_block { + continue; + } + + if whole_account { + if proof.storage_hash == baseline.last_root { + // Tight steady-state path: unchanged root ⇒ nothing. + continue; + } + // Root moved. + if !touched.contains(&address) { + // Moved with no covering decoder — the coverage gap. + reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport { + address, + block, + _network: PhantomData, + }))); + self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed); + resyncs.push(root_moved_account_resync( + address, + block, + AccountFieldMask { + balance: true, + nonce: true, + code: true, + }, + )); + } + // Adopt the new root whether or not a decoder covered it. + self.adopt_root(address, block, &proof); + } else { + // Scalars: compare the account fields directly (native changes do + // not move the storage root). + let balance_moved = proof.balance != baseline.balance; + let nonce_moved = proof.nonce != baseline.nonce; + let code_moved = proof.code_hash != baseline.code_hash; + if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) { + resyncs.push(root_moved_account_resync( + address, + block, + AccountFieldMask { + balance: balance_moved, + nonce: nonce_moved, + code: code_moved, + }, + )); + } + self.adopt_root(address, block, &proof); + } + } + } + + /// Adopt (or re-adopt) `proof` as the baseline for `address` at `block`. + fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) { + self.tracked_roots.insert( + address, + TrackedRoot { + last_root: proof.storage_hash, + last_block: block, + balance: proof.balance, + nonce: proof.nonce, + code_hash: proof.code_hash, + }, + ); + } + + fn execute_handlers( + &self, + cache: &EvmCache, + record: &ReactiveInputRecord, + input_ref: InputRef, + audience: &DeliveryAudience, + ) -> Result, ReactiveError> { + let mut executions = Vec::new(); + let candidates: Vec<_> = match &record.input { + ReactiveInput::Log(log) => self.registry.log_handler_candidates(log), + ReactiveInput::BlockHeader(_) + | ReactiveInput::FullBlock(_) + | ReactiveInput::PendingTxHash(_) + | ReactiveInput::PendingTx(_) => self.registry.handlers().collect(), + }; + for registered in candidates { + match audience { + DeliveryAudience::Owners(owners) if !owners.contains(®istered.id) => continue, + DeliveryAudience::AllExcept(excluded) if excluded.contains(®istered.id) => { + continue; + } + DeliveryAudience::All + | DeliveryAudience::Owners(_) + | DeliveryAudience::AllExcept(_) => {} + } + if !registered.matches(&record.input) { + continue; + } + + let outcome = registered + .handler + .handle(&record.context, &record.input, cache) + .map_err(|source| ReactiveError::HandlerFailed { + handler_id: registered.id.clone(), + source, + })?; + + if let Err(error) = + validate_effects(input_ref, &record.context, ®istered.id, &outcome.effects) + { + if matches!(error, ReactiveError::InvalidPendingEffect { .. }) { + self.metrics + .pending_contamination + .fetch_add(1, Ordering::Relaxed); + } + return Err(error); + } + executions.push(HandlerExecution::from_outcome( + registered.id.clone(), + input_ref, + outcome, + matches!( + record.context.chain_status, + ChainStatus::Preconfirmed { .. } + ), + )); + } + Ok(executions) + } + + fn dispatch_reports(&self, reports: &[Arc>]) { + for report in reports { + for hook in &self.hooks { + hook.on_report(report.clone()); + } + } + } + + fn apply_chain_control( + &mut self, + cache: &mut EvmCache, + control: ChainControl, + batch_report: &mut ReactiveBatchReport, + reports: &mut Vec>>, + ) { + match &control { + ChainControl::Safe(block) => set_or_enrich_block_ref(&mut self.safe_head, block), + ChainControl::Finalized(block) => { + set_or_enrich_block_ref(&mut self.finalized_head, block); + } + ChainControl::CanonicalProgress(block) + | ChainControl::Barrier { + block: Some(block), .. + } => { + let preserve_env = self.coverage_head.as_ref().is_some_and(|current| { + optional_block_refs_are_compatible(Some(current), Some(block)) + }); + cache.advance_compact_block( + block.number, + block.hash, + block.timestamp, + preserve_env, + ); + advance_or_enrich_coverage(&mut self.coverage_head, block); + let enriched = self.journal_entry_mut(block).block; + advance_or_enrich_coverage(&mut self.coverage_head, &enriched); + self.trim_journal(); + } + ChainControl::Barrier { block: None, .. } => {} + ChainControl::Reorg { + common_ancestor, + old_tip, + .. + } => { + cache.invalidate_cached_block_hashes_from(common_ancestor.number.saturating_add(1)); + self.rebase_validation_state_from(common_ancestor.number.saturating_add(1)); + let dropped = if let Some(ancestor_index) = self.journal.iter().rposition(|entry| { + entry.block.number == common_ancestor.number + && entry.block.hash == common_ancestor.hash + }) { + self.drain_journal_after(ancestor_index) + } else { + // Sparse journals are expected for blocks with no matching + // events. If the oldest retained entry is at or below the + // ancestor, every effect above it is still present and the + // rollback is complete even without an exact anchor. + if self + .journal + .front() + .is_none_or(|entry| entry.block.number > common_ancestor.number) + { + reports.extend( + self.warn_under_recovery(common_ancestor.number.saturating_add(1)), + ); + } + self.drain_journal_from_number(common_ancestor.number.saturating_add(1)) + }; + + let reorg_report = self + .recover_dropped_journals(cache, dropped, ReorgReason::Explicit) + .unwrap_or_else(|| ReorgReport { + dropped: Some(*old_tip), + dropped_blocks: Vec::new(), + dropped_inputs: Vec::new(), + rollback_updates: Vec::new(), + rollback_diff: StateDiff::default(), + purge_updates: Vec::new(), + purge_diff: StateDiff::default(), + canceled_resyncs: self + .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(old_tip)), + reason: ReorgReason::Explicit, + _network: PhantomData, + }); + remove_canceled_resyncs_from_batch( + &mut batch_report.resyncs, + &reorg_report.canceled_resyncs, + ); + self.metrics + .reorgs_recovered + .fetch_add(1, Ordering::Relaxed); + reports.push(Arc::new(ReactiveReport::Reorg(reorg_report))); + + if self.safe_head.as_ref().is_some_and(|head| { + head.number > common_ancestor.number + || (head.number == common_ancestor.number + && head.hash != common_ancestor.hash) + }) { + self.safe_head = None; + } + if self.finalized_head.as_ref().is_some_and(|head| { + head.number > common_ancestor.number + || (head.number == common_ancestor.number + && head.hash != common_ancestor.hash) + }) { + self.finalized_head = None; + } + let mut enriched_ancestor = *common_ancestor; + if let Some(entry) = self.journal.iter().find(|entry| { + entry.block.number == common_ancestor.number + && entry.block.hash == common_ancestor.hash + }) { + enrich_block_ref(&mut enriched_ancestor, &entry.block); + } + if let Some(current) = self.coverage_head.as_ref() + && current.number == common_ancestor.number + && current.hash == common_ancestor.hash + { + enrich_block_ref(&mut enriched_ancestor, current); + } + self.coverage_head = Some(enriched_ancestor); + cache.advance_compact_block( + enriched_ancestor.number, + enriched_ancestor.hash, + enriched_ancestor.timestamp, + false, + ); + let enriched_ancestor = self.journal_entry_mut(&enriched_ancestor).block; + self.coverage_head = Some(enriched_ancestor); + self.trim_journal(); + } + } + reports.push(Arc::new(ReactiveReport::ChainControl(ChainControlReport { + control, + }))); + } + + fn validate_ingest_sequence( + &self, + pre_record_controls: &[ChainControl], + post_record_controls: &[ChainControl], + records: &[(ReactiveInputRecord, DeliveryAudience, DeliveryScope)], + ) -> Result { + let mut controls = + Vec::with_capacity(pre_record_controls.len() + post_record_controls.len()); + controls.extend_from_slice(pre_record_controls); + controls.extend_from_slice(post_record_controls); + let state = CanonicalSequenceState::new( + self.journal.iter().map(|entry| entry.block).collect(), + self.coverage_head, + self.safe_head, + self.finalized_head, + ); + let record_metadata = records + .iter() + .map(|(record, _, scope)| (record, *scope)) + .collect::>(); + let validation = validate_canonical_sequence_parts( + &state, + &controls, + &record_metadata, + CanonicalSequenceValidationPolicy::ObserveIncompleteRollback, + ) + .map_err(CanonicalSequenceError::into_reactive_error)?; + let mut resolved_canonical_blocks = HashMap::new(); + for mutation in validation.mutations() { + if let CanonicalSequenceMutation::Canonical(block) = mutation { + resolved_canonical_blocks + .entry((block.number, block.hash)) + .and_modify(|known| enrich_block_ref(known, block)) + .or_insert(*block); + } + } + Ok(ChainControlState { + journal_invalidated_from: pre_record_controls + .iter() + .filter_map(|control| match control { + ChainControl::Reorg { + common_ancestor, .. + } => Some(common_ancestor.number.saturating_add(1)), + _ => None, + }) + .min(), + resolved_canonical_blocks, + }) + } + + fn recover_for_canonical_input( + &mut self, + cache: &mut EvmCache, + block: &BlockRef, + gap_is_certified: bool, + parentless_replacement_is_proven: bool, + health_reports: &mut Vec>>, + ) -> Option> { + let latest = self + .coverage_head + .or_else(|| self.journal.back().map(|entry| entry.block))?; + + if latest.number == block.number && latest.hash == block.hash { + return None; + } + + if self + .journal + .iter() + .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number) + { + return None; + } + + if latest.number.checked_add(1) == Some(block.number) + && (block.parent_hash == Some(latest.hash) + || (parentless_replacement_is_proven && block.parent_hash.is_none())) + { + return None; + } + + if latest + .number + .checked_add(1) + .is_some_and(|next| block.number > next) + { + // A forward gap: blocks between the journaled head and the arriving + // block were never observed (e.g. a disconnect). A historical + // canonical-progress delivery can instead be covered by a + // compatible post-record progress/barrier certificate proving the + // sparse interval contained no matching events. Live canonical + // gaps remain observable and escalate health. + if !gap_is_certified { + self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed); + health_reports.extend(self.escalate_trust(block.number)); + health_reports.push(Arc::new(ReactiveReport::MissedBlockRange( + MissedRangeReport { + from: latest.number + 1, + to: block.number - 1, + block: block.number, + _network: PhantomData, + }, + ))); + } + return None; + } + + let (dropped, authenticated_anchor) = if let Some(parent_hash) = block.parent_hash { + if let Some(parent_index) = self.journal.iter().rposition(|entry| { + entry.block.number.checked_add(1) == Some(block.number) + && entry.block.hash == parent_hash + }) { + let parent = self.journal[parent_index].block; + cache.invalidate_cached_block_hashes_from(parent.number.saturating_add(1)); + (self.drain_journal_after(parent_index), Some(parent)) + } else { + // An unknown immediate parent proves exactly N-1 and nothing + // earlier. Preserve a prefix only when the accepted path is an + // immediate child of the runtime's exact finalized anchor; + // otherwise every cached BLOCKHASH may belong to the displaced + // branch and must be cleared fail-closed. + let proven_finalized_anchor = self.finalized_head.filter(|finalized| { + finalized.number.checked_add(1) == Some(block.number) + && parent_hash == finalized.hash + }); + let invalidated_from = proven_finalized_anchor + .map_or(0, |finalized| finalized.number.saturating_add(1)); + cache.invalidate_cached_block_hashes_from(invalidated_from); + if block.number > 0 { + // Even when the parent falls outside the retained journal, + // the arriving child authenticates its exact hash. Restore + // that one known value after clearing the displaced branch. + cache.set_cached_block_hash(block.number.saturating_sub(1), parent_hash); + } + health_reports.extend(self.warn_under_recovery(block.number)); + let dropped = if let Some(finalized) = proven_finalized_anchor { + self.drain_journal_from_number(finalized.number.saturating_add(1)) + } else { + self.drain_journal_from_number(0) + }; + (dropped, proven_finalized_anchor) + } + } else { + // No parent identity authenticates any prefix of the arriving path. + cache.invalidate_cached_block_hashes_from(0); + health_reports.extend(self.warn_under_recovery(block.number)); + (self.drain_journal_from_number(0), None) + }; + + self.rebase_validation_state_from( + authenticated_anchor.map_or(0, |anchor| anchor.number.saturating_add(1)), + ); + let report = self + .recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch) + .or_else(|| { + Some(ReorgReport { + dropped: Some(latest), + dropped_blocks: Vec::new(), + dropped_inputs: Vec::new(), + rollback_updates: Vec::new(), + rollback_diff: StateDiff::default(), + purge_updates: Vec::new(), + purge_diff: StateDiff::default(), + canceled_resyncs: self + .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&latest)), + reason: ReorgReason::ParentMismatch, + _network: PhantomData, + }) + }); + self.coverage_head = authenticated_anchor; + for head in [&mut self.safe_head, &mut self.finalized_head] { + if head.is_some_and(|head| { + authenticated_anchor.is_none_or(|anchor| { + head.number > anchor.number + || (head.number == anchor.number && head.hash != anchor.hash) + }) + }) { + *head = None; + } + } + if let Some(anchor) = authenticated_anchor { + cache.advance_compact_block(anchor.number, anchor.hash, anchor.timestamp, false); + } + report + } + + fn recover_for_reorged_input( + &mut self, + cache: &mut EvmCache, + record: &ReactiveInputRecord, + batch_dropped: &mut BatchDroppedCanonical, + health_reports: &mut Vec>>, + ) -> Option> { + let (incoming_dropped_block, reason) = reorg_signal_block(record)?; + if batch_dropped.contains(&incoming_dropped_block) { + // A previous signal in this atomic batch already drained this + // block/span. Preserve the lifecycle input report, but do not + // repeat rollback or classify the provider's per-log removals as a + // deep reorg. Exact hash-pinned repairs still need cancellation. + let canceled_resyncs = self + .cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&incoming_dropped_block)); + return (!canceled_resyncs.is_empty()).then(|| ReorgReport { + dropped: Some(incoming_dropped_block), + dropped_blocks: vec![incoming_dropped_block], + dropped_inputs: Vec::new(), + rollback_updates: Vec::new(), + rollback_diff: StateDiff::default(), + purge_updates: Vec::new(), + purge_diff: StateDiff::default(), + canceled_resyncs, + reason, + _network: PhantomData, + }); + } + let exact_index = self.journal.iter().position(|entry| { + entry.block.number == incoming_dropped_block.number + && entry.block.hash == incoming_dropped_block.hash + }); + let mut dropped_block = exact_index + .map(|index| self.journal[index].block) + .or_else(|| { + self.coverage_head.filter(|known| { + known.number == incoming_dropped_block.number + && known.hash == incoming_dropped_block.hash + }) + }) + .unwrap_or(incoming_dropped_block); + enrich_block_ref(&mut dropped_block, &incoming_dropped_block); + let replacement_is_known = exact_index.is_none() + && (self.journal.iter().any(|entry| { + entry.block.number == dropped_block.number && entry.block.hash != dropped_block.hash + }) || self.coverage_head.is_some_and(|head| { + head.number == dropped_block.number && head.hash != dropped_block.hash + })); + + if replacement_is_known { + // A delayed/duplicate removed log for the displaced hash is + // idempotent. Draining by number here would destroy the already + // installed replacement branch at the same height. + let canceled_resyncs = + self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block)); + return (!canceled_resyncs.is_empty()).then(|| ReorgReport { + dropped: Some(dropped_block), + dropped_blocks: vec![dropped_block], + dropped_inputs: Vec::new(), + rollback_updates: Vec::new(), + rollback_diff: StateDiff::default(), + purge_updates: Vec::new(), + purge_diff: StateDiff::default(), + canceled_resyncs, + reason, + _network: PhantomData, + }); + } + + let authenticated_anchor = exact_index.and_then(|index| { + let ancestor_number = dropped_block.number.checked_sub(1)?; + let retained = self + .journal + .iter() + .take(index) + .rev() + .find(|entry| entry.block.number == ancestor_number) + .map(|entry| entry.block); + let synthetic_parent = dropped_block.parent_hash.map(|hash| BlockRef { + number: ancestor_number, + hash, + parent_hash: None, + timestamp: None, + }); + let finalized_fallback = self + .finalized_head + .filter(|head| head.number == ancestor_number); + let mut anchor = retained.or(synthetic_parent).or(finalized_fallback)?; + for head in [self.safe_head.as_ref(), self.finalized_head.as_ref()] + .into_iter() + .flatten() + { + if head.number == anchor.number && head.hash == anchor.hash { + enrich_block_ref(&mut anchor, head); + } + } + Some(anchor) + }); + + cache.invalidate_cached_block_hashes_from(dropped_block.number); + let dropped = if let Some(index) = exact_index { + self.drain_journal_from(index) + } else { + health_reports.extend(self.warn_under_recovery(dropped_block.number)); + self.drain_journal_from_number(dropped_block.number) + }; + let drained_blocks = dropped.iter().map(|entry| entry.block).collect::>(); + batch_dropped.record_drained(&drained_blocks); + batch_dropped.record_identity(&dropped_block); + self.rebase_validation_state_from(dropped_block.number); + + let recovered_journal = !dropped.is_empty(); + let report = if !recovered_journal { + let canceled_resyncs = + self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block)); + Some(ReorgReport { + dropped: Some(dropped_block), + dropped_blocks: Vec::new(), + dropped_inputs: Vec::new(), + rollback_updates: Vec::new(), + rollback_diff: StateDiff::default(), + purge_updates: Vec::new(), + purge_diff: StateDiff::default(), + canceled_resyncs, + reason, + _network: PhantomData, + }) + } else { + self.recover_dropped_journals(cache, dropped, reason) + }; + + if recovered_journal { + if let Some(anchor) = authenticated_anchor { + self.coverage_head = Some(anchor); + } + let coverage = self.coverage_head; + for head in [&mut self.safe_head, &mut self.finalized_head] { + if head.is_some_and(|head| { + coverage.is_none_or(|coverage| { + head.number > coverage.number + || (head.number == coverage.number && head.hash != coverage.hash) + }) + }) { + *head = None; + } + } + } + + if recovered_journal + && report.is_some() + && let Some(head) = self.coverage_head + { + cache.advance_compact_block(head.number, head.hash, head.timestamp, false); + } + report + } + + /// Warn that a reorg references a block no longer resident in the journal, so + /// recovery is limited to the blocks still journaled — effects from aged-out + /// blocks are neither rolled back nor purged (the freshness/validation loop is + /// the backstop). Makes the under-recovery observable instead of silent. + /// + /// This is a deep reorg: it increments the `deep_reorgs` counter and escalates + /// health along the trust-loss ladder via [`escalate_trust`](Self::escalate_trust) + /// (a first event degrades to [`CacheHealth::Degraded`], a second escalates to + /// [`CacheHealth::Unhealthy`]). Any resulting [`ReactiveReport::Health`] + /// transition is returned so the caller can thread it into the ingest cycle's + /// dispatched reports. + fn warn_under_recovery(&mut self, reorg_number: u64) -> Option>> { + let oldest_journaled = self.journal.front().map(|entry| entry.block.number); + tracing::warn!( + reorg_block = reorg_number, + oldest_journaled = ?oldest_journaled, + journal_depth = self.config.journal_depth, + "reactive reorg recovery is incomplete: the reorged block is no longer \ + in the journal, so effects from blocks aged out of the journal are \ + neither rolled back nor purged (the freshness/validation loop is the \ + backstop). Increase ReactiveConfig::journal_depth to recover deeper \ + reorgs precisely." + ); + + self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed); + + self.escalate_trust(reorg_number) + } + + fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) { + advance_or_enrich_coverage(&mut self.coverage_head, block); + let entry = self.journal_entry_mut(block); + let enriched = entry.block; + if !entry.inputs.contains(&input_ref) { + entry.inputs.push(input_ref); + } + advance_or_enrich_coverage(&mut self.coverage_head, &enriched); + self.trim_journal(); + } + + fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport) { + let entry = self.journal_entry_mut(block); + if !entry.handler_ids.contains(&applied.handler_id) { + entry.handler_ids.push(applied.handler_id.clone()); + } + entry.rollback_diffs.push(applied.diff.clone()); + entry.applied.push(applied); + self.trim_journal(); + } + + fn record_journal_applied_if_present(&mut self, block: &BlockRef, applied: AppliedReport) { + let Some(entry) = self + .journal + .iter_mut() + .find(|entry| entry.block.number == block.number && entry.block.hash == block.hash) + else { + return; + }; + if !entry.handler_ids.contains(&applied.handler_id) { + entry.handler_ids.push(applied.handler_id.clone()); + } + entry.rollback_diffs.push(applied.diff.clone()); + entry.applied.push(applied); + } + + fn record_journal_resync(&mut self, report: &ResyncReport) { + if report.diff.is_empty() { + return; + } + let Some(block) = single_hash_pinned_resync_block(report) else { + return; + }; + let entry = self.journal_entry_mut(&block); + entry.rollback_diffs.push(report.diff.clone()); + entry.resynced.push(report.clone()); + self.trim_journal(); + } + + fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal { + if let Some(index) = self + .journal + .iter() + .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number) + { + enrich_block_ref(&mut self.journal[index].block, block); + return &mut self.journal[index]; + } + + self.journal.push_back(BlockJournal { + block: *block, + inputs: Vec::new(), + applied: Vec::new(), + handler_ids: Vec::new(), + resynced: Vec::new(), + rollback_diffs: Vec::new(), + }); + let index = self.journal.len() - 1; + &mut self.journal[index] + } + + fn trim_journal(&mut self) { + if self.config.journal_depth == 0 { + self.journal.clear(); + return; + } + while self.journal.len() > self.config.journal_depth { + self.journal.pop_front(); + } + } + + fn drain_journal_after(&mut self, index: usize) -> Vec> { + self.journal.drain((index + 1)..).collect() + } + + fn drain_journal_from(&mut self, index: usize) -> Vec> { + self.journal.drain(index..).collect() + } + + fn drain_journal_from_number(&mut self, number: u64) -> Vec> { + let Some(index) = self + .journal + .iter() + .position(|entry| entry.block.number >= number) + else { + return Vec::new(); + }; + self.drain_journal_from(index) + } + + fn recover_dropped_journals( + &mut self, + cache: &mut EvmCache, + dropped: Vec>, + reason: ReorgReason, + ) -> Option> { + if dropped.is_empty() { + return None; + } + + let first_dropped_block = dropped + .iter() + .map(|entry| entry.block.number) + .min() + .expect("non-empty dropped journal set"); + self.rebase_validation_state_from(first_dropped_block); + if self + .safe_head + .is_some_and(|head| head.number >= first_dropped_block) + { + self.safe_head = None; + } + + let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block).collect(); + let dropped_inputs: Vec<_> = dropped + .iter() + .flat_map(|entry| entry.inputs.iter().copied()) + .collect(); + let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks); + let purge_scopes = purge_scopes_for_dropped_journals(&dropped); + let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes); + let purge_updates: Vec<_> = purge_scopes + .iter() + .map(|(address, scope)| StateUpdate::purge(*address, scope.clone())) + .collect(); + + let rollback_diff = if rollback_updates.is_empty() { + StateDiff::default() + } else { + cache.apply_updates(&rollback_updates) + }; + let purge_diff = if purge_updates.is_empty() { + StateDiff::default() + } else { + cache.apply_updates(&purge_updates) + }; + self.coverage_head = self.journal.back().map(|entry| entry.block); + + Some(ReorgReport { + dropped: dropped_blocks.first().cloned(), + dropped_blocks, + dropped_inputs, + rollback_updates, + rollback_diff, + purge_updates, + purge_diff, + canceled_resyncs, + reason, + _network: PhantomData, + }) + } + + fn rebase_validation_state_from(&mut self, first_dropped_block: u64) { + if let Some(freshness) = self.freshness.as_mut() { + freshness.invalidate_valid_through_from(first_dropped_block); + } + self.tracked_roots + .retain(|_, baseline| baseline.last_block < first_dropped_block); + if self + .last_gate_block + .is_some_and(|block| block >= first_dropped_block) + { + self.last_gate_block = self + .tracked_roots + .values() + .map(|baseline| baseline.last_block) + .max(); + } + // Touch provenance is window-relative. Once any block in that window + // is dropped, retaining the union could incorrectly mark a replacement + // branch root move as decoder-covered. + self.touched_since_gate.clear(); + } + + fn cancel_resyncs_for_dropped_blocks( + &mut self, + dropped_blocks: &[BlockRef], + ) -> Vec { + let mut canceled = Vec::new(); + self.pending_resyncs.retain(|request| { + let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks); + if should_cancel { + canceled.push(request.clone()); + } + !should_cancel + }); + canceled + } + + fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator) { + let ids: HashSet<_> = ids.into_iter().cloned().collect(); + self.pending_resyncs + .retain(|request| !ids.contains(&request.id)); + } +} + +/// Validate one provider-neutral delivery envelope without mutating runtime or +/// cache state. +/// +/// This is the canonical metadata contract shared by [`ReactiveRuntime`] and +/// composite/remote subscribers. It validates explicit reorg controls before +/// records, canonical record identity and implicit-reorg finality, then +/// progress/barrier/safe/finalized controls. All identity assertions in the +/// envelope must agree at each height. Retained history may be sparse; an +/// explicit common ancestor need not itself be retained when the oldest +/// retained entry is at or below it. Ancestors and removed blocks outside that +/// rollback horizon are rejected, so a durable caller cannot persist a partial +/// rollback. The runtime uses this same implementation with an internal +/// observable-deep-reorg policy for its deliberately non-durable ingest path. +/// +/// The returned state and mutations are cache-free. Callers that durably stage +/// delivery should publish/persist them only at their own acknowledgement +/// boundary. +/// +/// This validator is deliberately chain-agnostic and does not compare +/// [`ReactiveInputBatch::chain_id`] because [`CanonicalSequenceState`] carries +/// no chain id. Cross-service/composite callers must bind one authoritative +/// chain identity outside this state before sharing or advancing it; runtime +/// ingestion separately checks the batch id against [`EvmCache`]. +/// +/// # Errors +/// +/// Returns [`ReactiveError::InvalidInputRecord`] when record identity/payload +/// metadata is malformed or conflicting, and +/// [`ReactiveError::InvalidChainControl`] when the snapshot or envelope has an +/// invalid canonical transition, incomplete rollback proof, contradictory +/// identity, or invalid coverage/finality relationship. +pub fn validate_canonical_sequence( + state: &CanonicalSequenceState, + batch: &ReactiveInputBatch, +) -> Result { + validate_canonical_sequence_diagnostic(state, batch) + .map_err(CanonicalSequenceError::into_reactive_error) +} + +/// Validate one provider-neutral delivery envelope and retain structured +/// rollback diagnostics. +/// +/// This is the diagnostic counterpart to [`validate_canonical_sequence`]. Use +/// it at durable/composite source boundaries that need to distinguish malformed +/// input from an otherwise valid transition whose rollback ancestor has aged +/// out of the retained history. Callers should branch on +/// [`CanonicalSequenceError`] rather than parsing error text. +/// +/// # Errors +/// +/// Returns [`CanonicalSequenceError::Invalid`] for malformed or contradictory +/// state/input and [`CanonicalSequenceError::IncompleteRollback`] when more +/// retained canonical history is required to prove the transition. +pub fn validate_canonical_sequence_diagnostic( + state: &CanonicalSequenceState, + batch: &ReactiveInputBatch, +) -> Result { + validate_canonical_sequence_internal( + state, + batch, + CanonicalSequenceValidationPolicy::RequireCompleteRollback, + ) +} + +/// Validate a composite-source envelope and normalize harmless coverage +/// overlap. +/// +/// This has the same fail-closed rollback/finality/identity contract as +/// [`validate_canonical_sequence`]. In addition, an equal or older +/// [`ChainControl::CanonicalProgress`] whose exact compatible identity is +/// retained is omitted from [`CanonicalSequenceValidation::normalized_chain_controls`]. +/// A compatible stale blockful [`ChainControl::Barrier`] is retained with the +/// same opaque id and `block: None`, preserving the synchronization event +/// without forwarding regressive coverage. An equal-height control that fills +/// absent parent/timestamp metadata is retained and applied. Older compatible +/// metadata enrichment is deliberately dropped together with its non-forwarded +/// control so the returned state remains identical to what the runtime will +/// observe. Unknown or conflicting stale identities remain errors. +/// +/// # Errors +/// +/// Returns [`ReactiveError::InvalidInputRecord`] for malformed or conflicting +/// record identity/payload metadata, and +/// [`ReactiveError::InvalidChainControl`] when canonical overlap cannot be +/// proven redundant or when rollback, adjacency, identity, coverage, or +/// finality validation fails. +pub fn normalize_and_validate_canonical_sequence( + state: &CanonicalSequenceState, + batch: &ReactiveInputBatch, +) -> Result { + normalize_and_validate_canonical_sequence_diagnostic(state, batch) + .map_err(CanonicalSequenceError::into_reactive_error) +} + +/// Validate and normalize one composite-source envelope while retaining +/// structured rollback diagnostics. +/// +/// This is the diagnostic counterpart to +/// [`normalize_and_validate_canonical_sequence`]. It has identical transition +/// and normalization semantics, but reports history exhaustion as +/// [`CanonicalSequenceError::IncompleteRollback`] instead of folding it into a +/// prose [`ReactiveError::InvalidChainControl`]. +/// +/// # Errors +/// +/// Returns [`CanonicalSequenceError::Invalid`] for malformed, contradictory, or +/// non-normalizable input and [`CanonicalSequenceError::IncompleteRollback`] +/// when the retained history cannot prove a complete rollback. +pub fn normalize_and_validate_canonical_sequence_diagnostic( + state: &CanonicalSequenceState, + batch: &ReactiveInputBatch, +) -> Result { + validate_canonical_sequence_internal( + state, + batch, + CanonicalSequenceValidationPolicy::RequireCompleteRollbackNormalizeCoverage, + ) +} + +fn validate_canonical_sequence_internal( + state: &CanonicalSequenceState, + batch: &ReactiveInputBatch, + policy: CanonicalSequenceValidationPolicy, +) -> Result { + let records = batch + .records() + .iter() + .enumerate() + .map(|(index, record)| { + ( + record.clone(), + DeliveryAudience::All, + batch + .record_delivery_scope(index) + .expect("enumerated record always has a delivery scope"), + ) + }) + .collect::>(); + let records = sort_scoped_records(dedupe_scoped_records(records)?); + let records = records + .iter() + .map(|(record, _, scope)| (record, *scope)) + .collect::>(); + validate_canonical_sequence_parts(state, batch.chain_controls(), &records, policy) +} + +#[derive(Clone, Copy)] +enum CanonicalSequenceValidationPolicy { + RequireCompleteRollback, + RequireCompleteRollbackNormalizeCoverage, + ObserveIncompleteRollback, +} + +/// Stable category for a canonical transition that needs older retained +/// history before it can be durably accepted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum CanonicalRollbackKind { + /// An explicit reorg control names an ancestor outside retained history. + Explicit, + /// A removed/reorged record names a block outside retained history. + Removed, + /// An implicit canonical replacement has no retained parent proof. + ImplicitParent, + /// A removed block is not followed by a provable replacement/anchor. + MissingReplacement, +} + +/// Structured failure returned by canonical-sequence diagnostic validation. +/// +/// This type is intentionally independent of diagnostic prose so remote and +/// composite subscribers can select recovery behavior without string matching. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CanonicalSequenceError { + /// The snapshot or envelope is intrinsically malformed or contradictory. + #[error(transparent)] + Invalid(#[from] ReactiveError), + /// The transition may be valid, but its rollback proof lies outside the + /// supplied retained canonical history. + #[error( + "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}" + )] + IncompleteRollback { + /// Last ancestor height required to prove the rollback. + common_ancestor: u64, + /// Oldest retained canonical height supplied by the caller. + oldest_retained: Option, + /// Stable reason the history window is insufficient. + kind: CanonicalRollbackKind, + }, +} + +#[derive(Clone, Copy, Debug)] +struct RequiredReorgAnchor { + number: u64, + block: Option, + permits_missing_child_parent: bool, + must_be_consumed: bool, +} + +#[derive(Debug)] +struct SequenceRewind { + common_ancestor: Option, + dropped: Vec, +} + +impl RequiredReorgAnchor { + const fn hash(self) -> Option { + match self.block { + Some(block) => Some(block.hash), + None => None, + } + } +} + +impl CanonicalSequenceError { + /// Whether retrying with an older retained history window may prove this + /// same transition. + pub const fn requires_history(&self) -> bool { + matches!(self, Self::IncompleteRollback { .. }) + } + + /// Fold this structured diagnostic into the legacy ergonomic runtime error. + pub fn into_reactive_error(self) -> ReactiveError { + match self { + Self::Invalid(error) => error, + Self::IncompleteRollback { + common_ancestor, + oldest_retained, + kind, + } => ReactiveError::InvalidChainControl { + message: format!( + "{kind:?} rollback after block {common_ancestor} exceeds retained canonical history starting at {oldest_retained:?}" + ), + }, + } + } +} + +impl CanonicalSequenceValidationPolicy { + const fn requires_complete_rollback(self) -> bool { + matches!( + self, + Self::RequireCompleteRollback | Self::RequireCompleteRollbackNormalizeCoverage + ) + } + + const fn normalizes_coverage(self) -> bool { + matches!(self, Self::RequireCompleteRollbackNormalizeCoverage) + } +} + +fn validate_canonical_sequence_parts( + initial: &CanonicalSequenceState, + controls: &[ChainControl], + records: &[(&ReactiveInputRecord, DeliveryScope)], + policy: CanonicalSequenceValidationPolicy, +) -> Result { + validate_canonical_sequence_snapshot(initial)?; + let control_split = validate_control_phase_order(controls)?; + let (pre_record_controls, post_record_controls) = controls.split_at(control_split); + let mut state = initial.clone(); + let mut asserted_blocks = HashMap::::new(); + let mut mutations = Vec::new(); + let mut normalized_chain_controls = Vec::with_capacity(controls.len()); + let mut batch_dropped = BatchDroppedCanonical::default(); + let mut removed_assertions = HashMap::<(u64, B256), BlockRef>::new(); + let mut removed_heights_by_hash = HashMap::::new(); + let mut record_proof_control_identities = HashSet::<(u64, B256)>::new(); + let rollback_oldest = initial + .retained_canonical_history + .first() + .map(|block| block.number); + + for control in pre_record_controls { + normalized_chain_controls.push(control.clone()); + validate_sequence_control(&state, control)?; + assert_chain_control_identities(&mut asserted_blocks, control)?; + let ChainControl::Reorg { + common_ancestor, + old_tip, + .. + } = control + else { + unreachable!("phase validation leaves only reorg controls before records") + }; + let exact_ancestor = state.retained_canonical_history.iter().any(|block| { + block.number == common_ancestor.number && block.hash == common_ancestor.hash + }); + let rollback_horizon_covers_ancestor = state + .retained_canonical_history + .first() + .is_some_and(|oldest| oldest.number <= common_ancestor.number); + if policy.requires_complete_rollback() + && !exact_ancestor + && !rollback_horizon_covers_ancestor + { + return Err(CanonicalSequenceError::IncompleteRollback { + common_ancestor: common_ancestor.number, + oldest_retained: rollback_oldest, + kind: CanonicalRollbackKind::Explicit, + }); + } + let dropped = state + .retained_canonical_history + .iter() + .copied() + .filter(|block| block.number > common_ancestor.number) + .collect::>(); + state + .retained_canonical_history + .retain(|block| block.number <= common_ancestor.number); + upsert_sequence_history(&mut state.retained_canonical_history, common_ancestor)?; + let mut enriched_ancestor = *common_ancestor; + if let Some(retained) = state.retained_canonical_history.iter().find(|block| { + block.number == common_ancestor.number && block.hash == common_ancestor.hash + }) { + enrich_block_ref(&mut enriched_ancestor, retained); + } + if let Some(coverage) = state.coverage_head.as_ref() + && coverage.number == common_ancestor.number + && coverage.hash == common_ancestor.hash + { + enrich_block_ref(&mut enriched_ancestor, coverage); + } + upsert_sequence_history(&mut state.retained_canonical_history, &enriched_ancestor)?; + state.coverage_head = Some(enriched_ancestor); + clear_sequence_heads_above(&mut state, &enriched_ancestor); + batch_dropped.record_explicit(common_ancestor, old_tip); + batch_dropped.record_drained(&dropped); + mutations.push(CanonicalSequenceMutation::Rewind { + common_ancestor: Some(enriched_ancestor), + dropped, + }); + } + let pre_record_state = state.clone(); + let mut required_reorg_anchor = None::; + + for (record, scope) in records { + if !scope.advances_canonical_state() { + continue; + } + if let Some((incoming_dropped_block, _)) = reorg_signal_block(record) { + let incoming_dropped_block = + resolve_record_block_payload_metadata(record, incoming_dropped_block)?; + validate_sequence_matching_metadata(&state, &incoming_dropped_block, "removed record")?; + validate_sequence_adjacent_parent_identity( + &state, + &incoming_dropped_block, + "removed record", + )?; + let mut dropped_block = state + .retained_canonical_history + .iter() + .find(|known| { + known.number == incoming_dropped_block.number + && known.hash == incoming_dropped_block.hash + }) + .copied() + .or_else(|| { + state.coverage_head.filter(|known| { + known.number == incoming_dropped_block.number + && known.hash == incoming_dropped_block.hash + }) + }) + .unwrap_or(incoming_dropped_block); + enrich_block_ref(&mut dropped_block, &incoming_dropped_block); + validate_sequence_implicit_finality(&state, record, None)?; + if dropped_block.number == 0 { + return Err(ReactiveError::InvalidChainControl { + message: "a removed/reorged genesis block has no canonical parent anchor" + .into(), + } + .into()); + } + let removed_identity = (dropped_block.number, dropped_block.hash); + if let Some(previous_number) = + removed_heights_by_hash.insert(dropped_block.hash, dropped_block.number) + && previous_number != dropped_block.number + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "removed hash {:?} is reused at heights {} and {}", + dropped_block.hash, previous_number, dropped_block.number + ), + } + .into()); + } + if let Some(previous) = removed_assertions.get_mut(&removed_identity) { + if !optional_block_refs_are_compatible(Some(previous), Some(&dropped_block)) { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "duplicate removed block {}:{:?} carries conflicting metadata", + dropped_block.number, dropped_block.hash + ), + } + .into()); + } + enrich_block_ref(previous, &dropped_block); + } else { + removed_assertions.insert(removed_identity, dropped_block); + } + if asserted_blocks + .get(&dropped_block.number) + .is_some_and(|asserted| asserted.hash == dropped_block.hash) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "removed block {}:{:?} is asserted canonical by the same envelope", + dropped_block.number, dropped_block.hash + ), + } + .into()); + } + if batch_dropped.contains(&dropped_block) { + continue; + } + if let Some(index) = state.retained_canonical_history.iter().position(|block| { + block.number == dropped_block.number && block.hash == dropped_block.hash + }) { + let dropped = state.retained_canonical_history.split_off(index); + batch_dropped.record_drained(&dropped); + let ancestor_number = dropped_block + .number + .checked_sub(1) + .expect("genesis removal was rejected above"); + let retained_anchor = state + .retained_canonical_history + .iter() + .rev() + .find(|head| head.number == ancestor_number) + .copied(); + let authenticated_anchor = retained_anchor + .or_else(|| { + dropped_block.parent_hash.map(|hash| BlockRef { + number: ancestor_number, + hash, + parent_hash: None, + timestamp: None, + }) + }) + .or_else(|| { + state + .finalized_head + .filter(|head| head.number == ancestor_number) + }); + let authenticated_anchor = authenticated_anchor.map(|mut anchor| { + for head in [state.safe_head.as_ref(), state.finalized_head.as_ref()] + .into_iter() + .flatten() + { + if head.number == anchor.number && head.hash == anchor.hash { + enrich_block_ref(&mut anchor, head); + } + } + anchor + }); + required_reorg_anchor = Some(RequiredReorgAnchor { + number: ancestor_number, + block: authenticated_anchor, + permits_missing_child_parent: retained_anchor.is_some(), + must_be_consumed: authenticated_anchor.is_none() + && state.retained_canonical_history.is_empty(), + }); + state.coverage_head = authenticated_anchor + .or_else(|| state.retained_canonical_history.last().copied()); + if let Some(head) = state.coverage_head { + clear_sequence_heads_above(&mut state, &head); + } else { + state.safe_head = None; + state.finalized_head = None; + } + mutations.push(CanonicalSequenceMutation::Rewind { + common_ancestor: state.coverage_head, + dropped, + }); + } else { + let replacement_is_known = state.retained_canonical_history.iter().any(|block| { + block.number == dropped_block.number && block.hash != dropped_block.hash + }) || state.coverage_head.is_some_and(|head| { + head.number == dropped_block.number && head.hash != dropped_block.hash + }); + if !replacement_is_known { + // Ordinary runtime ingestion deliberately keeps an unknown + // deep removal observable and lets the recovery path + // degrade health. With no exact retained rollback proof, + // this validator must not fabricate a new canonical head. + if policy.requires_complete_rollback() { + return Err(CanonicalSequenceError::IncompleteRollback { + common_ancestor: dropped_block + .number + .checked_sub(1) + .expect("genesis removal was rejected above"), + oldest_retained: rollback_oldest, + kind: CanonicalRollbackKind::Removed, + }); + } + continue; + } + } + continue; + } + + let Some(context_block) = canonical_record_block(record) else { + continue; + }; + let incoming_block = resolve_record_block_payload_metadata(record, *context_block)?; + if post_record_controls + .iter() + .filter_map(canonical_coverage_control_block) + .any(|asserted| { + asserted.number == incoming_block.number + && asserted.hash == incoming_block.hash + && optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block)) + && ((incoming_block.parent_hash.is_none() && asserted.parent_hash.is_some()) + || (incoming_block.timestamp.is_none() && asserted.timestamp.is_some())) + }) + { + record_proof_control_identities.insert((incoming_block.number, incoming_block.hash)); + } + let mut resolved_block = incoming_block; + if let Some(asserted) = asserted_blocks + .get(&incoming_block.number) + .filter(|asserted| asserted.hash == incoming_block.hash) + { + if !optional_block_refs_are_compatible(Some(asserted), Some(&incoming_block)) { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical record {}:{:?} conflicts with the same envelope's asserted metadata", + incoming_block.number, incoming_block.hash + ), + } + .into()); + } + enrich_block_ref(&mut resolved_block, asserted); + } + for asserted in post_record_controls + .iter() + .filter_map(chain_control_canonical_assertion) + .filter(|asserted| { + asserted.number == incoming_block.number && asserted.hash == incoming_block.hash + }) + { + if !optional_block_refs_are_compatible(Some(&resolved_block), Some(asserted)) { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical record {}:{:?} conflicts with the same envelope's asserted metadata", + incoming_block.number, incoming_block.hash + ), + } + .into()); + } + enrich_block_ref(&mut resolved_block, asserted); + } + let replacement_anchor = + required_reorg_anchor.filter(|required| resolved_block.number > required.number); + if resolved_block.parent_hash.is_none() + && replacement_anchor.is_some_and(|anchor| { + anchor.permits_missing_child_parent + && anchor.number.checked_add(1) == Some(resolved_block.number) + }) + { + resolved_block.parent_hash = replacement_anchor.and_then(RequiredReorgAnchor::hash); + } + let block = &resolved_block; + if removed_assertions.contains_key(&(block.number, block.hash)) { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical block {}:{:?} is also removed by the same envelope", + block.number, block.hash + ), + } + .into()); + } + if let Some(removed_number) = removed_heights_by_hash.get(&block.hash) + && *removed_number != block.number + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical hash {:?} at height {} is removed at height {} by the same envelope", + block.hash, block.number, removed_number + ), + } + .into()); + } + let replacement_proven_by_removal = + validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?; + if replacement_anchor.is_some() { + required_reorg_anchor = None; + } + validate_sequence_matching_metadata(&state, block, "canonical record")?; + validate_sequence_implicit_finality(&state, record, Some(block))?; + let implicit_replacement_requires_history = if replacement_proven_by_removal { + false + } else { + sequence_implicit_replacement_requires_history(&state, block, policy)? + }; + if implicit_replacement_requires_history && policy.requires_complete_rollback() { + return Err(CanonicalSequenceError::IncompleteRollback { + common_ancestor: block.number.saturating_sub(1), + oldest_retained: rollback_oldest, + kind: CanonicalRollbackKind::ImplicitParent, + }); + } + assert_canonical_block_identity(&mut asserted_blocks, block, "canonical record")?; + let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| { + anchor.permits_missing_child_parent + && anchor.number.checked_add(1) == Some(block.number) + }); + if let Some(rewind) = + apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)? + { + mutations.push(CanonicalSequenceMutation::Rewind { + common_ancestor: rewind.common_ancestor, + dropped: rewind.dropped, + }); + } + mutations.push(CanonicalSequenceMutation::Canonical(*block)); + } + + for control in post_record_controls { + if let Some(block) = chain_control_canonical_assertion(control) + && removed_assertions.contains_key(&(block.number, block.hash)) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical block {}:{:?} is also removed by the same envelope", + block.number, block.hash + ), + } + .into()); + } + if let Some(block) = chain_control_canonical_assertion(control) + && let Some(removed_number) = removed_heights_by_hash.get(&block.hash) + && *removed_number != block.number + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical hash {:?} at height {} is removed at height {} by the same envelope", + block.hash, block.number, removed_number + ), + } + .into()); + } + let replacement_anchor = canonical_coverage_control_block(control).and_then(|block| { + required_reorg_anchor.filter(|required| block.number > required.number) + }); + if let Some(block) = canonical_coverage_control_block(control) { + validate_replacement_reorg_anchor(replacement_anchor, block, policy, rollback_oldest)?; + if replacement_anchor.is_some() { + required_reorg_anchor = None; + } + } + assert_chain_control_identities(&mut asserted_blocks, control)?; + let preserves_record_proof = + canonical_coverage_control_block(control).is_some_and(|block| { + record_proof_control_identities.contains(&(block.number, block.hash)) + }); + if policy.normalizes_coverage() + && !preserves_record_proof + && let Some(block) = canonical_coverage_control_block(control) + && state + .coverage_head + .is_some_and(|head| block.number <= head.number) + { + let is_equal_coverage = state + .coverage_head + .is_some_and(|head| block.number == head.number); + let known = state + .coverage_head + .as_ref() + .filter(|head| head.number == block.number && head.hash == block.hash) + .or_else(|| { + state + .retained_canonical_history + .iter() + .find(|entry| entry.number == block.number && entry.hash == block.hash) + }); + if let Some(known) = known + && optional_block_refs_are_compatible(Some(known), Some(block)) + && (!is_equal_coverage || !sequence_block_adds_metadata(&state, block)) + { + if let ChainControl::Barrier { id, .. } = control { + normalized_chain_controls.push(ChainControl::Barrier { + id: id.clone(), + block: None, + }); + } + continue; + } + } + validate_sequence_control(&state, control)?; + normalized_chain_controls.push(control.clone()); + match control { + ChainControl::Safe(block) => { + set_or_enrich_block_ref(&mut state.safe_head, block); + mutations.push(CanonicalSequenceMutation::Safe( + state.safe_head.expect("safe head was just installed"), + )); + } + ChainControl::Finalized(block) => { + set_or_enrich_block_ref(&mut state.finalized_head, block); + mutations.push(CanonicalSequenceMutation::Finalized( + state + .finalized_head + .expect("finalized head was just installed"), + )); + } + ChainControl::CanonicalProgress(block) + | ChainControl::Barrier { + block: Some(block), .. + } => { + let allow_parentless_extension = replacement_anchor.is_some_and(|anchor| { + anchor.permits_missing_child_parent + && anchor.number.checked_add(1) == Some(block.number) + }) || (replacement_anchor.is_none() + && block.parent_hash.is_none() + && state + .coverage_head + .is_some_and(|head| head.number.checked_add(1) == Some(block.number))); + if let Some(rewind) = + apply_sequence_canonical_block(&mut state, block, allow_parentless_extension)? + { + mutations.push(CanonicalSequenceMutation::Rewind { + common_ancestor: rewind.common_ancestor, + dropped: rewind.dropped, + }); + } + mutations.push(CanonicalSequenceMutation::Canonical(*block)); + } + ChainControl::Barrier { block: None, .. } => {} + ChainControl::Reorg { .. } => { + unreachable!("phase validation excludes post-record reorg controls") + } + } + } + + if let Some(required) = required_reorg_anchor + && required.must_be_consumed + && policy.requires_complete_rollback() + { + return Err(CanonicalSequenceError::IncompleteRollback { + common_ancestor: required.number, + oldest_retained: rollback_oldest, + kind: CanonicalRollbackKind::MissingReplacement, + }); + } + + validate_canonical_sequence_snapshot(&state)?; + Ok(CanonicalSequenceValidation { + pre_record_state, + next_state: state, + mutations, + normalized_chain_controls, + }) +} + +fn validate_canonical_sequence_snapshot( + state: &CanonicalSequenceState, +) -> Result<(), ReactiveError> { + let invalid = |message: String| ReactiveError::InvalidChainControl { message }; + let supplied_blocks = state + .retained_canonical_history + .iter() + .chain(state.coverage_head.iter()) + .chain(state.safe_head.iter()) + .chain(state.finalized_head.iter()) + .collect::>(); + validate_known_parent_hash_heights(&supplied_blocks)?; + let mut prior = None::; + for block in &state.retained_canonical_history { + if let Some(previous) = prior { + if block.number < previous.number { + return Err(invalid( + "retained canonical history is not ordered by block number".into(), + )); + } + if block.number == previous.number { + let qualifier = if optional_block_refs_are_compatible(Some(&previous), Some(block)) + { + "duplicate" + } else { + "conflicting" + }; + return Err(invalid(format!( + "retained canonical history contains {qualifier} identities at block {}", + block.number + ))); + } + if previous.number.checked_add(1) == Some(block.number) + && block.parent_hash.is_some() + && block.parent_hash != Some(previous.hash) + { + return Err(invalid(format!( + "adjacent retained block {}:{:?} does not descend from {}:{:?}", + block.number, block.hash, previous.number, previous.hash + ))); + } + } + prior = Some(*block); + } + if state.coverage_head.is_none() && !state.retained_canonical_history.is_empty() { + return Err(invalid( + "retained canonical history requires an authoritative coverage head".into(), + )); + } + if let Some(head) = state.coverage_head.as_ref() { + if let Some(retained) = state + .retained_canonical_history + .iter() + .find(|entry| entry.number == head.number) + && !optional_block_refs_are_compatible(Some(retained), Some(head)) + { + return Err(invalid(format!( + "coverage head {}:{:?} conflicts with retained identity {:?}", + head.number, head.hash, retained + ))); + } + if state + .retained_canonical_history + .last() + .is_some_and(|retained| retained.number > head.number) + { + return Err(invalid( + "retained canonical history advances beyond the coverage head".into(), + )); + } + if let Some(retained) = state.retained_canonical_history.last() + && retained.number.checked_add(1) == Some(head.number) + && head.parent_hash.is_some() + && head.parent_hash != Some(retained.hash) + { + return Err(invalid(format!( + "coverage head {}:{:?} does not descend from adjacent retained block {}:{:?}", + head.number, head.hash, retained.number, retained.hash + ))); + } + } + if let Some(safe) = state.safe_head.as_ref() { + validate_sequence_known_identity(state, safe, "safe")?; + validate_sequence_head_within_coverage(state, safe, "safe")?; + validate_coverage_descends_from_adjacent_head(state.coverage_head.as_ref(), safe, "safe")?; + } + if let Some(finalized) = state.finalized_head.as_ref() { + validate_sequence_known_identity(state, finalized, "finalized")?; + validate_sequence_head_within_coverage(state, finalized, "finalized")?; + validate_coverage_descends_from_adjacent_head( + state.coverage_head.as_ref(), + finalized, + "finalized", + )?; + } + validate_adjacent_finality(state.finalized_head.as_ref(), state.safe_head.as_ref())?; + if let (Some(finalized), Some(safe)) = (state.finalized_head, state.safe_head) + && (finalized.number > safe.number + || (finalized.number == safe.number && finalized.hash != safe.hash)) + { + return Err(invalid( + "finalized head cannot advance beyond or conflict with safe head".into(), + )); + } + Ok(()) +} + +fn validate_known_parent_hash_heights(blocks: &[&BlockRef]) -> Result<(), ReactiveError> { + let mut heights_by_hash = HashMap::::with_capacity(blocks.len()); + let mut resolved_by_height = HashMap::::with_capacity(blocks.len()); + for block in blocks.iter().copied() { + if let Some(previous_height) = heights_by_hash.insert(block.hash, block.number) + && previous_height != block.number + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical hash {:?} is reused at heights {} and {}", + block.hash, previous_height, block.number + ), + }); + } + if let Some(resolved) = resolved_by_height.get_mut(&block.number) { + if !optional_block_refs_are_compatible(Some(resolved), Some(block)) { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical aliases at height {} carry conflicting identities or metadata", + block.number + ), + }); + } + enrich_block_ref(resolved, block); + } else { + resolved_by_height.insert(block.number, *block); + } + } + for child in resolved_by_height.values() { + let Some(parent_hash) = child.parent_hash else { + continue; + }; + if let Some(parent_number) = heights_by_hash.get(&parent_hash) + && parent_number.checked_add(1) != Some(child.number) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent", + child.number, child.hash, parent_hash, parent_number + ), + }); + } + if let Some(parent_number) = child.number.checked_sub(1) + && let Some(parent) = resolved_by_height.get(&parent_number) + && parent.hash != parent_hash + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "block {}:{:?} does not descend from supplied adjacent identity {}:{:?}", + child.number, child.hash, parent.number, parent.hash + ), + }); + } + } + Ok(()) +} + +fn validate_coverage_descends_from_adjacent_head( + coverage: Option<&BlockRef>, + head: &BlockRef, + label: &str, +) -> Result<(), ReactiveError> { + let Some(coverage) = coverage else { + return Ok(()); + }; + if head.number.checked_add(1) == Some(coverage.number) + && coverage + .parent_hash + .is_some_and(|parent| parent != head.hash) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical coverage {}:{:?} does not descend from adjacent {label} head {}:{:?}", + coverage.number, coverage.hash, head.number, head.hash + ), + }); + } + Ok(()) +} + +fn validate_sequence_control( + state: &CanonicalSequenceState, + control: &ChainControl, +) -> Result<(), ReactiveError> { + let invalid = |message: String| ReactiveError::InvalidChainControl { message }; + match control { + ChainControl::Safe(block) => { + validate_sequence_known_identity(state, block, "safe")?; + validate_sequence_head_within_coverage(state, block, "safe")?; + if let Some(current) = state.safe_head.as_ref() + && (block.number < current.number + || (block.number == current.number + && (block.hash != current.hash + || !optional_block_refs_are_compatible(Some(block), Some(current))))) + { + return Err(invalid(format!( + "safe head {}:{:?} conflicts with current {}:{:?}", + block.number, block.hash, current.number, current.hash + ))); + } + if let Some(finalized) = state.finalized_head.as_ref() + && (block.number < finalized.number + || (block.number == finalized.number && block.hash != finalized.hash)) + { + return Err(invalid( + "safe head cannot precede or conflict with finalized head".into(), + )); + } + validate_adjacent_finality(state.finalized_head.as_ref(), Some(block))?; + } + ChainControl::Finalized(block) => { + validate_sequence_known_identity(state, block, "finalized")?; + validate_sequence_head_within_coverage(state, block, "finalized")?; + if let Some(current) = state.finalized_head.as_ref() + && (block.number < current.number + || (block.number == current.number + && (block.hash != current.hash + || !optional_block_refs_are_compatible(Some(block), Some(current))))) + { + return Err(invalid(format!( + "finalized head {}:{:?} conflicts with current {}:{:?}", + block.number, block.hash, current.number, current.hash + ))); + } + if let Some(safe) = state.safe_head.as_ref() + && (block.number > safe.number + || (block.number == safe.number && block.hash != safe.hash)) + { + return Err(invalid( + "finalized head cannot advance beyond or conflict with safe head".into(), + )); + } + validate_adjacent_finality(Some(block), state.safe_head.as_ref())?; + } + ChainControl::CanonicalProgress(block) + | ChainControl::Barrier { + block: Some(block), .. + } => { + validate_sequence_known_identity(state, block, "canonical coverage")?; + if let Some(current) = state.coverage_head.as_ref() + && (block.number < current.number + || (block.number == current.number && block.hash != current.hash)) + { + return Err(invalid(format!( + "canonical coverage {}:{:?} conflicts with current {}:{:?}", + block.number, block.hash, current.number, current.hash + ))); + } + if let Some(current) = state.coverage_head.as_ref() + && current.number.checked_add(1) == Some(block.number) + && block.parent_hash.is_some() + && block.parent_hash != Some(current.hash) + { + return Err(invalid(format!( + "canonical coverage {}:{:?} does not descend from current {}:{:?}", + block.number, block.hash, current.number, current.hash + ))); + } + } + ChainControl::Barrier { block: None, .. } => {} + ChainControl::Reorg { + common_ancestor, + old_tip, + new_tip, + } => { + validate_sequence_known_identity(state, common_ancestor, "reorg common ancestor")?; + validate_reorg_ancestor_against_retained_branch(state, common_ancestor)?; + validate_sequence_known_hash_height(state, old_tip, "reorg old tip")?; + validate_sequence_known_hash_height(state, new_tip, "reorg new tip")?; + validate_sequence_known_parent_height(state, old_tip, "reorg old tip")?; + validate_sequence_known_parent_height(state, new_tip, "reorg new tip")?; + validate_sequence_adjacent_parent_identity(state, old_tip, "reorg old tip")?; + if let Some(current) = state.coverage_head.as_ref() + && (old_tip.number != current.number + || old_tip.hash != current.hash + || !optional_block_refs_are_compatible(Some(old_tip), Some(current))) + { + return Err(invalid(format!( + "reorg old tip {}:{:?} does not exactly match current metadata {}:{:?}", + old_tip.number, old_tip.hash, current.number, current.hash + ))); + } + if common_ancestor.number > old_tip.number || common_ancestor.number > new_tip.number { + return Err(invalid( + "reorg common ancestor cannot be above either branch tip".into(), + )); + } + if common_ancestor.number == old_tip.number || common_ancestor.number == new_tip.number + { + return Err(invalid( + "reorg must replace non-empty old and new branches above the common ancestor" + .into(), + )); + } + if old_tip.number == new_tip.number && old_tip.hash == new_tip.hash { + return Err(invalid( + "reorg old and new tips cannot have the same canonical identity".into(), + )); + } + for (label, tip) in [("old", old_tip), ("new", new_tip)] { + if common_ancestor.number.checked_add(1) == Some(tip.number) + && tip.parent_hash != Some(common_ancestor.hash) + { + return Err(invalid(format!( + "reorg {label} tip does not descend from the common ancestor" + ))); + } + } + if let Some(finalized) = state.finalized_head.as_ref() + && (common_ancestor.number < finalized.number + || (common_ancestor.number == finalized.number + && common_ancestor.hash != finalized.hash)) + { + return Err(invalid( + "reorg would cross or conflict with the finalized head".into(), + )); + } + } + } + Ok(()) +} + +fn validate_sequence_known_identity( + state: &CanonicalSequenceState, + block: &BlockRef, + label: &str, +) -> Result<(), ReactiveError> { + validate_sequence_known_hash_height(state, block, label)?; + validate_sequence_known_parent_height(state, block, label)?; + let known = state + .coverage_head + .as_ref() + .filter(|head| head.number == block.number) + .or_else(|| { + state + .retained_canonical_history + .iter() + .find(|entry| entry.number == block.number) + }); + if let Some(known) = known + && !optional_block_refs_are_compatible(Some(known), Some(block)) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "{label} block {}:{:?} conflicts with known canonical block {:?}", + block.number, block.hash, known + ), + }); + } + Ok(()) +} + +fn validate_sequence_known_parent_height( + state: &CanonicalSequenceState, + block: &BlockRef, + label: &str, +) -> Result<(), ReactiveError> { + let Some(parent_hash) = block.parent_hash else { + return Ok(()); + }; + let known_parent = state + .retained_canonical_history + .iter() + .chain(state.coverage_head.iter()) + .chain(state.safe_head.iter()) + .chain(state.finalized_head.iter()) + .find(|known| known.hash == parent_hash); + if let Some(parent) = known_parent + && parent.number.checked_add(1) != Some(block.number) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent", + block.number, block.hash, parent.hash, parent.number + ), + }); + } + Ok(()) +} + +fn validate_sequence_head_within_coverage( + state: &CanonicalSequenceState, + block: &BlockRef, + label: &str, +) -> Result<(), ReactiveError> { + let Some(coverage) = state.coverage_head.as_ref() else { + return Err(ReactiveError::InvalidChainControl { + message: format!("{label} head requires an authoritative coverage head"), + }); + }; + if block.number > coverage.number + || (block.number == coverage.number + && !optional_block_refs_are_compatible(Some(block), Some(coverage))) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "{label} head {}:{:?} advances beyond or conflicts with coverage {}:{:?}", + block.number, block.hash, coverage.number, coverage.hash + ), + }); + } + Ok(()) +} + +fn validate_sequence_matching_metadata( + state: &CanonicalSequenceState, + block: &BlockRef, + label: &str, +) -> Result<(), ReactiveError> { + validate_sequence_known_hash_height(state, block, label)?; + validate_sequence_known_parent_height(state, block, label)?; + let known = state + .coverage_head + .as_ref() + .filter(|head| head.number == block.number && head.hash == block.hash) + .or_else(|| { + state + .retained_canonical_history + .iter() + .find(|entry| entry.number == block.number && entry.hash == block.hash) + }); + if let Some(known) = known + && !optional_block_refs_are_compatible(Some(known), Some(block)) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "{label} block {}:{:?} carries metadata conflicting with known canonical block {:?}", + block.number, block.hash, known + ), + }); + } + Ok(()) +} + +fn validate_sequence_known_hash_height( + state: &CanonicalSequenceState, + block: &BlockRef, + label: &str, +) -> Result<(), ReactiveError> { + let known = state + .retained_canonical_history + .iter() + .chain(state.coverage_head.iter()) + .chain(state.safe_head.iter()) + .chain(state.finalized_head.iter()) + .find(|known| known.hash == block.hash); + if let Some(known) = known + && known.number != block.number + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "{label} block {}:{:?} reuses a canonical hash already known at height {}", + block.number, block.hash, known.number + ), + }); + } + Ok(()) +} + +fn validate_reorg_ancestor_against_retained_branch( + state: &CanonicalSequenceState, + ancestor: &BlockRef, +) -> Result<(), ReactiveError> { + let adjacent_number = ancestor.number.checked_add(1); + for retained in state + .retained_canonical_history + .iter() + .chain(state.coverage_head.iter()) + .chain(state.safe_head.iter()) + .chain(state.finalized_head.iter()) + { + if Some(retained.number) == adjacent_number + && retained + .parent_hash + .is_some_and(|parent| parent != ancestor.hash) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "reorg common ancestor {}:{:?} conflicts with retained child {}:{:?} parent {:?}", + ancestor.number, + ancestor.hash, + retained.number, + retained.hash, + retained.parent_hash + ), + }); + } + if retained.parent_hash == Some(ancestor.hash) && Some(retained.number) != adjacent_number { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "reorg common ancestor {}:{:?} is named as the non-adjacent parent of retained block {}:{:?}", + ancestor.number, ancestor.hash, retained.number, retained.hash + ), + }); + } + } + Ok(()) +} + +fn validate_sequence_adjacent_parent_identity( + state: &CanonicalSequenceState, + block: &BlockRef, + label: &str, +) -> Result<(), ReactiveError> { + let Some(parent_hash) = block.parent_hash else { + return Ok(()); + }; + let Some(parent_number) = block.number.checked_sub(1) else { + return Ok(()); + }; + let known_parent = state + .retained_canonical_history + .iter() + .chain(state.coverage_head.iter()) + .chain(state.safe_head.iter()) + .chain(state.finalized_head.iter()) + .find(|known| known.number == parent_number); + if let Some(known_parent) = known_parent + && known_parent.hash != parent_hash + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "{label} block {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}", + block.number, block.hash, parent_hash, known_parent.number, known_parent.hash + ), + }); + } + Ok(()) +} + +fn sequence_block_adds_metadata(state: &CanonicalSequenceState, incoming: &BlockRef) -> bool { + state + .coverage_head + .iter() + .chain(state.retained_canonical_history.iter()) + .filter(|known| known.number == incoming.number && known.hash == incoming.hash) + .any(|known| { + (known.parent_hash.is_none() && incoming.parent_hash.is_some()) + || (known.timestamp.is_none() && incoming.timestamp.is_some()) + }) +} + +fn validate_sequence_implicit_finality( + state: &CanonicalSequenceState, + record: &ReactiveInputRecord, + resolved_canonical_block: Option<&BlockRef>, +) -> Result<(), ReactiveError> { + let Some(finalized) = state.finalized_head.as_ref() else { + return Ok(()); + }; + if let Some((dropped, _)) = reorg_signal_block(record) { + if dropped.number <= finalized.number { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "implicit reorg at {}:{:?} would cross finalized head {}:{:?}", + dropped.number, dropped.hash, finalized.number, finalized.hash + ), + }); + } + return Ok(()); + } + let Some(block) = resolved_canonical_block.or_else(|| canonical_record_block(record)) else { + return Ok(()); + }; + let Some(latest) = state.coverage_head.as_ref() else { + return Ok(()); + }; + if (block.number == latest.number && block.hash == latest.hash) + || state + .retained_canonical_history + .iter() + .any(|entry| entry.number == block.number && entry.hash == block.hash) + || (latest.number.checked_add(1) == Some(block.number) + && block.parent_hash == Some(latest.hash)) + || latest + .number + .checked_add(1) + .is_some_and(|next| block.number > next) + { + return Ok(()); + } + let crosses_finalized = if block.number <= finalized.number { + true + } else if let Some(parent_hash) = block.parent_hash { + if finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash { + false + } else if let Some(parent_index) = + state.retained_canonical_history.iter().rposition(|entry| { + entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash + }) + { + state + .retained_canonical_history + .iter() + .skip(parent_index + 1) + .any(|entry| entry.number <= finalized.number) + } else { + true + } + } else { + true + }; + if crosses_finalized { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical input {}:{:?} would replace finalized head {}:{:?}", + block.number, block.hash, finalized.number, finalized.hash + ), + }); + } + Ok(()) +} + +fn validate_required_reorg_anchor( + required: Option, + block: &BlockRef, +) -> Result<(), ReactiveError> { + let Some(required) = required else { + return Ok(()); + }; + let ancestor_hash = required.hash(); + let restores_ancestor = + block.number == required.number && ancestor_hash.is_some_and(|hash| block.hash == hash); + let replaces_removed_child = required.number.checked_add(1) == Some(block.number) + && ancestor_hash.is_some() + && (block.parent_hash == ancestor_hash + || (block.parent_hash.is_none() && required.permits_missing_child_parent)); + if restores_ancestor || replaces_removed_child { + return Ok(()); + } + Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical replacement {}:{:?} does not prove the removed tip's parent at block {}", + block.number, block.hash, required.number + ), + }) +} + +fn validate_replacement_reorg_anchor( + required: Option, + block: &BlockRef, + policy: CanonicalSequenceValidationPolicy, + oldest_retained: Option, +) -> Result { + let Some(required) = required else { + return Ok(false); + }; + match validate_required_reorg_anchor(Some(required), block) { + Ok(()) => Ok(true), + Err(error) if required.block.is_some() => Err(error.into()), + Err(_) if policy.requires_complete_rollback() => { + Err(CanonicalSequenceError::IncompleteRollback { + common_ancestor: required.number, + oldest_retained, + kind: CanonicalRollbackKind::MissingReplacement, + }) + } + Err(_) => Ok(false), + } +} + +fn apply_sequence_canonical_block( + state: &mut CanonicalSequenceState, + block: &BlockRef, + allow_parentless_adjacent_extension: bool, +) -> Result, ReactiveError> { + let latest = state.coverage_head; + let already_known = state + .retained_canonical_history + .iter() + .any(|entry| entry.number == block.number && entry.hash == block.hash); + let repeats_tip = + latest.is_some_and(|head| head.number == block.number && head.hash == block.hash); + let extends_tip = latest.is_some_and(|head| { + head.number.checked_add(1) == Some(block.number) + && (block.parent_hash == Some(head.hash) + || (allow_parentless_adjacent_extension && block.parent_hash.is_none())) + }); + let forward_gap = latest.is_some_and(|head| { + head.number + .checked_add(1) + .is_some_and(|next| block.number > next) + }); + let mut rewind = None; + + if latest.is_some() && !already_known && !repeats_tip && !extends_tip && !forward_gap { + let retained_parent = block.parent_hash.and_then(|parent_hash| { + state + .retained_canonical_history + .iter() + .rposition(|entry| { + entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash + }) + .map(|index| (index, state.retained_canonical_history[index])) + }); + let finalized_parent = block.parent_hash.and_then(|parent_hash| { + state.finalized_head.filter(|finalized| { + finalized.number.checked_add(1) == Some(block.number) + && finalized.hash == parent_hash + }) + }); + let (common_ancestor, dropped) = if let Some((parent_index, parent)) = retained_parent { + let dropped = state.retained_canonical_history.split_off(parent_index + 1); + (Some(parent), dropped) + } else if let Some(finalized) = finalized_parent { + let dropped = state + .retained_canonical_history + .iter() + .position(|entry| entry.number > finalized.number) + .map_or_else(Vec::new, |index| { + state.retained_canonical_history.split_off(index) + }); + (Some(finalized), dropped) + } else { + // The observable runtime policy may continue after an incomplete + // rollback proof so it can degrade health and repair. The metadata + // validator must nevertheless avoid claiming any old prefix is an + // ancestor of the arriving branch: without the exact N-1 parent, + // no retained identity is authenticated. + (None, std::mem::take(&mut state.retained_canonical_history)) + }; + state.coverage_head = common_ancestor; + if let Some(common_ancestor) = common_ancestor { + clear_sequence_heads_above(state, &common_ancestor); + } else { + state.safe_head = None; + state.finalized_head = None; + } + rewind = Some(SequenceRewind { + common_ancestor, + dropped, + }); + } + upsert_sequence_history(&mut state.retained_canonical_history, block)?; + advance_or_enrich_coverage(&mut state.coverage_head, block); + Ok(rewind) +} + +fn sequence_implicit_replacement_requires_history( + state: &CanonicalSequenceState, + block: &BlockRef, + policy: CanonicalSequenceValidationPolicy, +) -> Result { + let Some(latest) = state.coverage_head else { + return Ok(false); + }; + let already_known = state + .retained_canonical_history + .iter() + .any(|entry| entry.number == block.number && entry.hash == block.hash); + let repeats_tip = block.number == latest.number && block.hash == latest.hash; + let extends_tip = latest.number.checked_add(1) == Some(block.number) + && block.parent_hash == Some(latest.hash); + let forward_gap = latest + .number + .checked_add(1) + .is_some_and(|next| block.number > next); + if already_known || repeats_tip || extends_tip || forward_gap { + return Ok(false); + } + let Some(parent_hash) = block.parent_hash else { + if policy.requires_complete_rollback() { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "implicit canonical replacement {}:{:?} must identify its parent", + block.number, block.hash + ), + }); + } + return Ok(true); + }; + let known_adjacent_parent = block.number.checked_sub(1).and_then(|parent_number| { + state + .retained_canonical_history + .iter() + .chain(state.coverage_head.iter()) + .chain(state.safe_head.iter()) + .chain(state.finalized_head.iter()) + .find(|known| known.number == parent_number) + }); + if let Some(known_parent) = known_adjacent_parent + && known_parent.hash != parent_hash + && policy.requires_complete_rollback() + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "implicit canonical replacement {}:{:?} names parent {:?}, which conflicts with known adjacent block {}:{:?}", + block.number, block.hash, parent_hash, known_parent.number, known_parent.hash + ), + }); + } + let retained_parent = state.retained_canonical_history.iter().any(|entry| { + entry.number.checked_add(1) == Some(block.number) && entry.hash == parent_hash + }); + let finalized_parent = state.finalized_head.is_some_and(|finalized| { + finalized.number.checked_add(1) == Some(block.number) && parent_hash == finalized.hash + }); + Ok(!retained_parent && !finalized_parent) +} + +fn upsert_sequence_history( + history: &mut Vec, + block: &BlockRef, +) -> Result<(), ReactiveError> { + if let Some(existing) = history + .iter_mut() + .find(|entry| entry.number == block.number) + { + if existing.hash != block.hash { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical block {}:{:?} conflicts with retained identity {:?}", + block.number, block.hash, existing + ), + }); + } + if !optional_block_refs_are_compatible(Some(existing), Some(block)) { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "canonical block {}:{:?} carries conflicting retained metadata", + block.number, block.hash + ), + }); + } + enrich_block_ref(existing, block); + } else { + history.push(*block); + history.sort_by_key(|entry| entry.number); + } + Ok(()) +} + +fn clear_sequence_heads_above(state: &mut CanonicalSequenceState, ancestor: &BlockRef) { + if state.safe_head.as_ref().is_some_and(|head| { + head.number > ancestor.number + || (head.number == ancestor.number && head.hash != ancestor.hash) + }) { + state.safe_head = None; + } + if state.finalized_head.as_ref().is_some_and(|head| { + head.number > ancestor.number + || (head.number == ancestor.number && head.hash != ancestor.hash) + }) { + state.finalized_head = None; + } +} + +fn validate_control_phase_order(controls: &[ChainControl]) -> Result { + let split = controls + .iter() + .position(|control| !matches!(control, ChainControl::Reorg { .. })) + .unwrap_or(controls.len()); + if controls[split..] + .iter() + .any(|control| matches!(control, ChainControl::Reorg { .. })) + { + return Err(ReactiveError::InvalidChainControl { + message: "reorg controls must precede records and all post-record controls in a batch" + .into(), + }); + } + Ok(split) +} + +fn canonical_coverage_control_block(control: &ChainControl) -> Option<&BlockRef> { + match control { + ChainControl::CanonicalProgress(block) + | ChainControl::Barrier { + block: Some(block), .. + } => Some(block), + ChainControl::Reorg { .. } + | ChainControl::Safe(_) + | ChainControl::Finalized(_) + | ChainControl::Barrier { block: None, .. } => None, + } +} + +fn chain_control_canonical_assertion(control: &ChainControl) -> Option<&BlockRef> { + match control { + ChainControl::Safe(block) + | ChainControl::Finalized(block) + | ChainControl::CanonicalProgress(block) + | ChainControl::Barrier { + block: Some(block), .. + } => Some(block), + ChainControl::Reorg { .. } | ChainControl::Barrier { block: None, .. } => None, + } +} + +fn assert_chain_control_identities( + asserted_blocks: &mut HashMap, + control: &ChainControl, +) -> Result<(), ReactiveError> { + match control { + ChainControl::Safe(block) + | ChainControl::Finalized(block) + | ChainControl::CanonicalProgress(block) + | ChainControl::Barrier { + block: Some(block), .. + } => assert_canonical_block_identity(asserted_blocks, block, "chain control"), + ChainControl::Barrier { block: None, .. } => Ok(()), + ChainControl::Reorg { + common_ancestor, + new_tip, + .. + } => { + asserted_blocks.retain(|number, _| *number <= common_ancestor.number); + assert_canonical_block_identity( + asserted_blocks, + common_ancestor, + "reorg common ancestor", + )?; + assert_canonical_block_identity(asserted_blocks, new_tip, "reorg new tip") + } + } +} + +fn assert_canonical_block_identity( + asserted_blocks: &mut HashMap, + block: &BlockRef, + label: &str, +) -> Result<(), ReactiveError> { + for asserted in asserted_blocks.values() { + if asserted.hash == block.hash && asserted.number != block.number { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "{label} hash {:?} is already asserted at height {}, not {}", + block.hash, asserted.number, block.number + ), + }); + } + if block + .parent_hash + .is_some_and(|parent| parent == asserted.hash) + && asserted.number.checked_add(1) != Some(block.number) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "{label} block {}:{:?} names hash {:?} from known height {} as a non-adjacent parent", + block.number, block.hash, asserted.hash, asserted.number + ), + }); + } + if asserted + .parent_hash + .is_some_and(|parent| parent == block.hash) + && block.number.checked_add(1) != Some(asserted.number) + { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "block {}:{:?} asserted earlier names {label} hash {:?} from non-adjacent height {} as its parent", + asserted.number, asserted.hash, block.hash, block.number + ), + }); + } + } + if let Some(known) = asserted_blocks.get_mut(&block.number) { + if !optional_block_refs_are_compatible(Some(known), Some(block)) { + return Err(ReactiveError::InvalidChainControl { + message: format!( + "{label} block {}:{:?} conflicts with block identity {:?} asserted earlier in the batch", + block.number, block.hash, known + ), + }); + } + enrich_block_ref(known, block); + } else { + asserted_blocks.insert(block.number, *block); + } + Ok(()) +} + +fn set_or_enrich_block_ref(current: &mut Option, incoming: &BlockRef) { + match current { + Some(current) if current.number == incoming.number && current.hash == incoming.hash => { + enrich_block_ref(current, incoming); + } + _ => *current = Some(*incoming), + } +} + +fn advance_or_enrich_coverage(current: &mut Option, incoming: &BlockRef) { + match current { + Some(current) if current.number == incoming.number && current.hash == incoming.hash => { + enrich_block_ref(current, incoming); + } + Some(current) if current.number >= incoming.number => {} + _ => *current = Some(*incoming), + } +} + +fn validate_adjacent_finality( + finalized: Option<&BlockRef>, + safe: Option<&BlockRef>, +) -> Result<(), ReactiveError> { + let Some((finalized, safe)) = finalized.zip(safe) else { + return Ok(()); + }; + if finalized.number.checked_add(1) == Some(safe.number) + && safe.parent_hash != Some(finalized.hash) + { + return Err(ReactiveError::InvalidChainControl { + message: "adjacent safe head does not descend from finalized head".into(), + }); + } + Ok(()) +} + +/// Fold every address a [`StateDiff`] references — genuine changes +/// (`slots`/`accounts`/`purged`) and cold-skipped attempts (`skipped*`) alike — +/// into `into`. Used by the per-block root gate to accumulate the batch's +/// decoder-touched address set: an account a decoder wrote (or tried to write) is +/// "covered," so a subsequent root move for it is not a coverage gap. +fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet
) { + into.extend(diff.slots.iter().map(|change| change.address)); + into.extend(diff.accounts.iter().map(|change| change.address)); + into.extend(diff.purged.iter().map(|purge| purge.address)); + into.extend(diff.skipped.iter().map(|skipped| skipped.address)); + into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address)); + into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address)); + into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address)); +} + +/// Build the [`ResyncReason::RootMoved`] account resync the root gate schedules +/// for an uncovered move. Re-reads `address`'s `fields` at `block` through the +/// existing account-resync path (Wave 2). The id is derived from the address and +/// block so a repeated move on the same account/block coalesces deterministically. +fn root_moved_account_resync( + address: Address, + block: u64, + fields: AccountFieldMask, +) -> ResyncRequest { + ResyncRequest { + id: ResyncId::new(format!("root-moved:{address:#x}:{block}")), + reason: ResyncReason::RootMoved, + block: ResyncBlock::Number(block), + targets: vec![ResyncTarget::Account { address, fields }], + priority: ResyncPriority::Normal, + } +} + +fn batch_preconfirmation( + batch: &ReactiveInputBatch, +) -> Result, ReactiveError> { + let mut flashblock: Option = None; + let mut has_non_preconfirmed = false; + for (index, record) in batch.records().iter().enumerate() { + match &record.context.chain_status { + ChainStatus::Preconfirmed { + flashblock: current, + } => { + if batch.record_delivery_scope(index) != Some(DeliveryScope::Preconfirmed) { + return Err(ReactiveError::InvalidInputRecord { + message: "pre-confirmed input requires pre-confirmed delivery scope".into(), + }); + } + if flashblock.as_ref().is_some_and(|known| known != current) { + return Err(ReactiveError::InvalidInputRecord { + message: "one batch cannot mix distinct Flashblock snapshots".into(), + }); + } + flashblock.get_or_insert_with(|| current.clone()); + } + _ => has_non_preconfirmed = true, + } + } + if flashblock.is_some() && (has_non_preconfirmed || !batch.chain_controls().is_empty()) { + return Err(ReactiveError::InvalidInputRecord { + message: "pre-confirmed delivery cannot mix canonical inputs or chain controls".into(), + }); + } + Ok(flashblock) +} + +fn canonical_record_block(record: &ReactiveInputRecord) -> Option<&BlockRef> { + if matches!(&record.input, ReactiveInput::Log(log) if log.removed) { + return None; + } + if is_canonical_status(&record.context.chain_status) { + return context_block_ref(&record.context); + } + None +} + +fn resolve_record_block_payload_metadata( + record: &ReactiveInputRecord, + mut block: BlockRef, +) -> Result { + let ReactiveInput::Log(log) = &record.input else { + return Ok(block); + }; + if log.block_number != Some(block.number) || log.block_hash != Some(block.hash) { + return Err(ReactiveError::InvalidInputRecord { + message: "log payload and canonical context carry different block identities".into(), + }); + } + if let Some(timestamp) = log.block_timestamp { + if block.timestamp.is_some_and(|known| known != timestamp) { + return Err(ReactiveError::InvalidInputRecord { + message: "log payload and canonical context carry different block timestamps" + .into(), + }); + } + block.timestamp = Some(timestamp); + } + Ok(block) +} + +fn validate_input_record(record: &ReactiveInputRecord) -> Result<(), ReactiveError> { + let invalid = |message: String| ReactiveError::InvalidInputRecord { message }; + if let ChainStatus::Preconfirmed { flashblock } = &record.context.chain_status + && record.context.block != Some(flashblock.block_ref()) + { + return Err(invalid( + "pre-confirmed status and context carry different partial block identities".into(), + )); + } + let status_block = match &record.context.chain_status { + ChainStatus::Included { block, .. } + | ChainStatus::Safe { block } + | ChainStatus::Finalized { block } + | ChainStatus::Reorged { + dropped_from: block, + } => Some(block), + ChainStatus::Preconfirmed { .. } => record.context.block.as_ref(), + ChainStatus::Pending => None, + }; + match (status_block, record.context.block.as_ref()) { + (Some(status), Some(context)) if status == context => {} + (Some(_), Some(_)) => { + return Err(invalid( + "chain status and context carry different block identities".into(), + )); + } + (Some(_), None) => { + return Err(invalid( + "included or reorged input is missing its context block".into(), + )); + } + (None, Some(_)) => { + return Err(invalid( + "pending input cannot carry a canonical context block".into(), + )); + } + (None, None) => {} + } + + match &record.input { + ReactiveInput::Log(log) => { + let Some(block) = status_block else { + return Err(invalid( + "log input must carry an included or reorged block identity".into(), + )); + }; + if log.removed && !matches!(record.context.chain_status, ChainStatus::Reorged { .. }) { + return Err(invalid( + "removed log must carry reorged chain status".into(), + )); + } + let block_number = log + .block_number + .ok_or_else(|| invalid("log is missing its block number".into()))?; + let block_hash = log + .block_hash + .ok_or_else(|| invalid("log is missing its block hash".into()))?; + log.transaction_hash + .ok_or_else(|| invalid("log is missing its transaction hash".into()))?; + let transaction_index = log + .transaction_index + .ok_or_else(|| invalid("log is missing its transaction index".into()))?; + let log_index = log + .log_index + .ok_or_else(|| invalid("log is missing its log index".into()))?; + if block_number != block.number + || block_hash != block.hash + || !optional_metadata_compatible( + log.block_timestamp.as_ref(), + block.timestamp.as_ref(), + ) + { + return Err(invalid( + "log payload and context carry different block identities".into(), + )); + } + if record.context.transaction_index != Some(transaction_index) + || record.context.log_index != Some(log_index) + { + return Err(invalid( + "log payload and context carry different transaction/log positions".into(), + )); + } + } + ReactiveInput::BlockHeader(header) => { + if let Some(block) = status_block { + if header.number() != block.number + || header.hash() != block.hash + || Some(header.parent_hash()) != block.parent_hash + || Some(header.timestamp()) != block.timestamp + { + return Err(invalid( + "block header payload and context carry different block identities".into(), + )); + } + } else if !matches!(record.context.chain_status, ChainStatus::Pending) { + return Err(invalid("block header has an unsupported lifecycle".into())); + } + if record.context.transaction_index.is_some() || record.context.log_index.is_some() { + return Err(invalid( + "block header context cannot carry transaction/log positions".into(), + )); + } + } + ReactiveInput::FullBlock(block_response) => { + let header = block_response.header(); + if let Some(block) = status_block { + if header.number() != block.number + || header.hash() != block.hash + || Some(header.parent_hash()) != block.parent_hash + || Some(header.timestamp()) != block.timestamp + { + return Err(invalid( + "full-block payload and context carry different block identities".into(), + )); + } + } else if !matches!(record.context.chain_status, ChainStatus::Pending) { + return Err(invalid("full block has an unsupported lifecycle".into())); + } + if record.context.transaction_index.is_some() || record.context.log_index.is_some() { + return Err(invalid( + "full-block context cannot carry transaction/log positions".into(), + )); + } + if let Some(transactions) = block_response.transactions().as_transactions() { + for (index, transaction) in transactions.iter().enumerate() { + if transaction + .block_hash() + .is_some_and(|hash| hash != header.hash()) + || transaction + .block_number() + .is_some_and(|number| number != header.number()) + || transaction + .transaction_index() + .is_some_and(|position| position != index as u64) + { + return Err(invalid(format!( + "full-block transaction {index} carries contradictory inclusion metadata" + ))); + } + if transaction + .chain_id() + .zip(record.context.chain_id) + .is_some_and(|(transaction, context)| transaction != context) + { + return Err(invalid(format!( + "full-block transaction {index} carries a chain id conflicting with its context" + ))); + } + } + } + } + ReactiveInput::PendingTxHash(_) => { + if !matches!(record.context.chain_status, ChainStatus::Pending) { + return Err(invalid( + "pending transaction input must carry pending chain status".into(), + )); + } + if record.context.transaction_index.is_some() || record.context.log_index.is_some() { + return Err(invalid( + "pending transaction context cannot carry canonical positions".into(), + )); + } + } + ReactiveInput::PendingTx(transaction) => { + if !matches!(record.context.chain_status, ChainStatus::Pending) { + return Err(invalid( + "pending transaction input must carry pending chain status".into(), + )); + } + if record.context.transaction_index.is_some() || record.context.log_index.is_some() { + return Err(invalid( + "pending transaction context cannot carry canonical positions".into(), + )); + } + if transaction.block_hash().is_some() + || transaction.block_number().is_some() + || transaction.transaction_index().is_some() + { + return Err(invalid( + "hydrated pending transaction cannot carry inclusion metadata".into(), + )); + } + if transaction + .chain_id() + .zip(record.context.chain_id) + .is_some_and(|(transaction, context)| transaction != context) + { + return Err(invalid( + "pending transaction carries a chain id conflicting with its context".into(), + )); + } + } + } + Ok(()) +} + +/// Best-effort per-block env refresh (Phase-8 step 2). +/// +/// For a canonical record carrying a full header — a +/// [`ReactiveInput::BlockHeader`] or [`ReactiveInput::FullBlock`] — refresh the +/// cache's block env from that header via [`EvmCache::advance_block`]. Returns +/// `Some(result)` when a header was present (so the caller can surface a strict +/// validation error), and `None` for pending/reorged records or non-header +/// inputs, which must never drive a canonical env refresh. +fn advance_block_for_canonical_record( + cache: &mut EvmCache, + record: &ReactiveInputRecord, +) -> Option> { + if !is_canonical_status(&record.context.chain_status) { + return None; + } + match &record.input { + ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)), + ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())), + _ => None, + } +} + +fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> { + match &ctx.chain_status { + ChainStatus::Included { block, .. } + | ChainStatus::Safe { block } + | ChainStatus::Finalized { block } => Some(block), + ChainStatus::Reorged { dropped_from } => Some(dropped_from), + ChainStatus::Preconfirmed { .. } => ctx.block.as_ref(), + ChainStatus::Pending => ctx.block.as_ref(), + } +} + +fn reorg_signal_block( + record: &ReactiveInputRecord, +) -> Option<(BlockRef, ReorgReason)> { + if matches!(&record.input, ReactiveInput::Log(log) if log.removed) { + return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog)); + } + + if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status { + return Some((*dropped_from, ReorgReason::ReorgedInput)); + } + + None +} + +fn block_ref_from_record(record: &ReactiveInputRecord) -> Option { + context_block_ref(&record.context) + .cloned() + .or_else(|| match &record.input { + ReactiveInput::Log(log) => Some(BlockRef { + number: log.block_number?, + hash: log.block_hash?, + parent_hash: None, + timestamp: log.block_timestamp, + }), + ReactiveInput::BlockHeader(header) => Some(BlockRef { + number: header.number(), + hash: header.hash(), + parent_hash: Some(header.parent_hash()), + timestamp: Some(header.timestamp()), + }), + ReactiveInput::FullBlock(block) => { + let header = block.header(); + Some(BlockRef { + number: header.number(), + hash: header.hash(), + parent_hash: Some(header.parent_hash()), + timestamp: Some(header.timestamp()), + }) + } + ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None, + }) +} + +fn remove_canceled_resyncs_from_batch( + resyncs: &mut Vec, + canceled: &[ResyncRequest], +) { + if canceled.is_empty() { + return; + } + let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect(); + resyncs.retain(|request| !canceled_ids.contains(&request.id)); +} + +fn resync_target_address(target: &ResyncTarget) -> Address { + match target { + ResyncTarget::StorageSlot { address, .. } + | ResyncTarget::StorageSlots { address, .. } + | ResyncTarget::Account { address, .. } => *address, + } +} + +fn resync_request_targets_dropped_block( + request: &ResyncRequest, + dropped_blocks: &[BlockRef], +) -> bool { + let ResyncBlock::Hash { number, hash, .. } = &request.block else { + return false; + }; + dropped_blocks + .iter() + .any(|block| block.hash == *hash && block.number == *number) +} + +fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option { + let first = report.requested.first()?.block.clone(); + if !report + .requested + .iter() + .all(|request| request.block == first) + { + return None; + } + + let ResyncBlock::Hash { number, hash, .. } = first else { + return None; + }; + + Some(BlockRef { + number, + hash, + parent_hash: None, + timestamp: None, + }) +} + +fn purge_scopes_for_dropped_journals( + dropped: &[BlockJournal], +) -> Vec<(Address, PurgeScope)> { + let mut scopes: Vec<(Address, PurgeScope)> = Vec::new(); + for entry in dropped.iter().rev() { + for diff in entry.rollback_diffs.iter().rev() { + merge_purge_scopes_for_diff(&mut scopes, diff); + } + } + scopes +} + +fn rollback_updates_for_dropped_journals( + dropped: &[BlockJournal], + purge_scopes: &[(Address, PurgeScope)], +) -> Vec { + let purge_addresses: HashSet<_> = purge_scopes + .iter() + .map(|(address, _scope)| *address) + .collect(); + let mut updates = Vec::new(); + for entry in dropped.iter().rev() { + for diff in entry.rollback_diffs.iter().rev() { + push_rollback_updates_for_diff(&mut updates, diff, &purge_addresses); + } + } + updates +} + +fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) { + for change in &diff.accounts { + merge_purge_scope(scopes, change.address, PurgeScope::Account); + } + for record in &diff.purged { + merge_purge_scope(scopes, record.address, record.scope.clone()); + } +} + +fn push_rollback_updates_for_diff( + updates: &mut Vec, + diff: &StateDiff, + purge_addresses: &HashSet
, +) { + for change in diff.slots.iter().rev() { + if purge_addresses.contains(&change.address) { + continue; + } + updates.push(StateUpdate::slot(change.address, change.slot, change.old)); + } +} + +fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) { + if let Some((_existing_address, existing_scope)) = scopes + .iter_mut() + .find(|(existing_address, _scope)| *existing_address == address) + { + *existing_scope = merged_purge_scope(existing_scope.clone(), scope); + } else { + scopes.push((address, scope)); + } +} + +fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope { + match (left, right) { + (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account, + (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage, + (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => { + for slot in right { + if !left.contains(&slot) { + left.push(slot); + } + } + PurgeScope::Slots(left) + } + } +} + +#[derive(Clone, Debug)] +struct StorageFetchSlot { + address: Address, + slot: U256, + origins: Vec, +} + +#[derive(Clone, Debug)] +struct StorageFetchOrigin { + request_id: ResyncId, + target: ResyncTarget, +} + +#[derive(Clone, Debug)] +struct StorageFetchGroup { + block: ResyncBlock, + slots: Vec, + seen: HashSet<(Address, U256)>, +} + +/// One account-target resync collected during request scanning, resolved through +/// the account proof fetcher after storage groups are processed. +#[derive(Clone, Debug)] +struct AccountResyncTarget { + request_id: ResyncId, + block: ResyncBlock, + address: Address, + fields: AccountFieldMask, +} + +fn resolve_trace_resyncs( + cache: &EvmCache, + storage_groups: &mut Vec, + account_targets: &mut Vec, + state_updates: &mut Vec, +) { + let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else { + return; + }; + + let mut blocks = Vec::new(); + let mut seen = HashSet::new(); + for block in storage_groups + .iter() + .map(|group| group.block.clone()) + .chain(account_targets.iter().map(|target| target.block.clone())) + { + if seen.insert(block.clone()) { + blocks.push(block); + } + } + + let mut traces = HashMap::new(); + for block in blocks { + match (fetcher)(resync_block_to_block_id(&block)) { + Ok(diff) => { + traces.insert(block, diff); + } + Err(error) => { + tracing::debug!( + block = ?block, + error = %error, + "block trace resync source failed; falling back to point resync" + ); + } + } + } + + for group in storage_groups.iter_mut() { + let Some(trace) = traces.get(&group.block) else { + continue; + }; + group.slots.retain(|slot| { + if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) { + state_updates.push(StateUpdate::slot(slot.address, slot.slot, value)); + return false; + } + cache + .cached_storage_value(slot.address, slot.slot) + .is_none() + }); + group.seen = group + .slots + .iter() + .map(|slot| (slot.address, slot.slot)) + .collect(); + } + storage_groups.retain(|group| !group.slots.is_empty()); + + let mut unresolved_accounts = Vec::new(); + for mut account in account_targets.drain(..) { + let Some(trace) = traces.get(&account.block) else { + unresolved_accounts.push(account); + continue; + }; + let Some(trace_account) = trace + .accounts + .iter() + .find(|diff| diff.address == account.address) + else { + unresolved_accounts.push(account); + continue; + }; + + let mut patch = AccountPatch::default(); + let mut unresolved = AccountFieldMask::default(); + if account.fields.balance { + if let Some(balance) = trace_account.balance { + patch = patch.balance(balance); + } else { + unresolved.balance = true; + } + } + if account.fields.nonce { + if let Some(nonce) = trace_account.nonce { + patch = patch.nonce(nonce); + } else { + unresolved.nonce = true; + } + } + if account.fields.code { + if let Some(code) = &trace_account.code { + patch = patch.code(code.clone()); + } else { + unresolved.code = true; + } + } + + if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() { + state_updates.push(StateUpdate::account_upsert(account.address, patch)); + } + if !account_field_mask_empty(unresolved) { + account.fields = unresolved; + unresolved_accounts.push(account); + } + } + *account_targets = unresolved_accounts; +} + +fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option { + trace + .accounts + .iter() + .find(|account| account.address == address) + .and_then(|account| { + account + .storage .iter() - .any(|applied| &applied.handler_id == handler_id) + .find(|entry| entry.slot == slot) + .map(|entry| entry.value) }) - } +} - /// Return the distinct handler generations represented in the retained - /// reorg journal. - /// - /// This scans the bounded journal once, allowing a lifecycle owner to age a - /// large set of cache-eviction fences without rescanning the journal for - /// every handler. - pub fn journaled_handler_ids(&self) -> HashSet { - self.journal - .iter() - .flat_map(|entry| entry.applied.iter()) - .map(|applied| applied.handler_id.clone()) - .collect() - } +fn account_field_mask_empty(mask: AccountFieldMask) -> bool { + !mask.balance && !mask.nonce && !mask.code +} - /// Queued resync requests: surfaced by handlers but not yet executed by an - /// [`ingest_batch_with_resync`](Self::ingest_batch_with_resync) pass. - /// - /// Callers driving resync execution themselves (plain - /// [`ingest_batch`](Self::ingest_batch) loops) can read the ledger here; - /// reorg recovery cancels entries whose pinned blocks were dropped, and - /// [`cancel_pending_resync`](Self::cancel_pending_resync) drops exact - /// generation-owned work, while - /// [`cancel_pending_resyncs`](Self::cancel_pending_resyncs) drops entries - /// for exclusively torn-down accounts. - pub fn pending_resyncs(&self) -> &[ResyncRequest] { - &self.pending_resyncs - } +fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport { + let mut failed = Vec::new(); + let mut storage_groups: Vec = Vec::new(); + let mut account_targets: Vec = Vec::new(); - /// Cancel every queued request with the exact logical `id`. - /// - /// Unlike [`cancel_pending_resyncs`](Self::cancel_pending_resyncs), this - /// removes whole requests and never touches other work merely because it - /// targets the same account. It is therefore the safe primitive for - /// generation-scoped owner teardown when the caller maintains an - /// owner-to-[`ResyncId`] index. Requests already returned to the caller in - /// an earlier batch report cannot be recalled. - pub fn cancel_pending_resync(&mut self, id: &ResyncId) -> Vec { - self.cancel_pending_resyncs_by_id(std::slice::from_ref(id)) + for request in requests { + for target in &request.targets { + match target { + ResyncTarget::StorageSlot { address, slot } => { + push_storage_resync_slot( + &mut storage_groups, + &request.id, + &request.block, + *address, + *slot, + ); + } + ResyncTarget::StorageSlots { address, slots } => { + for slot in slots { + push_storage_resync_slot( + &mut storage_groups, + &request.id, + &request.block, + *address, + *slot, + ); + } + } + ResyncTarget::Account { address, fields } => { + account_targets.push(AccountResyncTarget { + request_id: request.id.clone(), + block: request.block.clone(), + address: *address, + fields: *fields, + }); + } + } + } } - /// Cancel queued requests whose logical ids occur in `ids` in one queue pass. - /// - /// Duplicate and unknown ids are harmless. Cancelled requests retain their - /// pending-queue order, independent of caller id order. This is the batch - /// teardown primitive for owners that can have many pending repairs; it - /// avoids rescanning the complete pending queue once per owned id. - pub fn cancel_pending_resyncs_by_id(&mut self, ids: &[ResyncId]) -> Vec { - if ids.is_empty() { - return Vec::new(); - } - let ids: HashSet<&ResyncId> = ids.iter().collect(); - let mut cancelled = Vec::new(); - self.pending_resyncs.retain(|request| { - if ids.contains(&request.id) { - cancelled.push(request.clone()); - false - } else { - true + let mut state_updates = Vec::new(); + resolve_trace_resyncs( + cache, + &mut storage_groups, + &mut account_targets, + &mut state_updates, + ); + + if !storage_groups.is_empty() { + if let Some(fetcher) = cache.storage_batch_fetcher().cloned() { + for group in storage_groups { + let block = group.block.clone(); + let fetches: Vec<(Address, U256)> = group + .slots + .iter() + .map(|slot| (slot.address, slot.slot)) + .collect(); + let results = (fetcher)(fetches, resync_block_to_block_id(&block)); + let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group + .slots + .iter() + .cloned() + .map(|slot| ((slot.address, slot.slot), slot)) + .collect(); + + for (address, slot, fetched) in results { + let Some(requested_slot) = pending.remove(&(address, slot)) else { + continue; + }; + match fetched { + Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)), + Err(error) => { + let message = error.to_string(); + push_resync_failures( + &mut failed, + &block, + requested_slot.origins, + ResyncFailureKind::StorageFetchFailed, + message, + ); + } + } + } + + for requested_slot in group.slots { + if pending + .remove(&(requested_slot.address, requested_slot.slot)) + .is_some() + { + push_resync_failures( + &mut failed, + &block, + requested_slot.origins, + ResyncFailureKind::StorageFetchOmitted, + "storage batch fetcher did not return a value for slot".to_string(), + ); + } + } } - }); - cancelled + } else { + for group in storage_groups { + let block = group.block.clone(); + for slot in group.slots { + push_resync_failures( + &mut failed, + &block, + slot.origins, + ResyncFailureKind::MissingStorageFetcher, + "storage resync requires a storage batch fetcher".to_string(), + ); + } + } + } } - /// Cancel queued resync work that targets `address`, returning the - /// cancelled portions. - /// - /// Every pending [`ResyncRequest`] target referencing `address` is removed; - /// a request reduced to zero targets is dropped entirely, while - /// mixed-target requests keep their other accounts queued. Each returned - /// request mirrors the original id/reason/block/priority and carries only - /// the targets that were cancelled. - /// - /// This is appropriate only when the caller owns the complete account. For - /// a pool sharing a vault or emitter with other owners, cancel its exact - /// request IDs through - /// [`cancel_pending_resync`](Self::cancel_pending_resync) instead. It cannot - /// recall requests already returned to the caller in earlier batch reports. - pub fn cancel_pending_resyncs(&mut self, address: Address) -> Vec { - let mut cancelled = Vec::new(); - self.pending_resyncs.retain_mut(|request| { - let (matching, remaining): (Vec<_>, Vec<_>) = request - .targets - .drain(..) - .partition(|target| resync_target_address(target) == address); - request.targets = remaining; - if !matching.is_empty() { - cancelled.push(ResyncRequest { - id: request.id.clone(), - reason: request.reason.clone(), - block: request.block.clone(), - targets: matching, - priority: request.priority, + if !account_targets.is_empty() { + if let Some(fetcher) = cache.account_proof_fetcher().cloned() { + // ONE seam invocation per distinct resync block (targets may pin + // different blocks): eth_getProof is single-address at the RPC + // level, so batching the addresses lets the fetcher fan the + // requests out concurrently instead of paying one round trip per + // account. Root-only probes: account fields need no storage keys. + let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new(); + for account in account_targets { + let block_id = resync_block_to_block_id(&account.block); + match groups + .iter_mut() + .find(|(group_block, _)| *group_block == block_id) + { + Some((_, group)) => group.push(account), + None => groups.push((block_id, vec![account])), + } + } + for (block_id, group) in groups { + let probes: HashMap> = (fetcher)( + group + .iter() + .map(|account| (account.address, vec![])) + .collect(), + block_id, + ) + .into_iter() + .collect(); + for account in group { + // `get` + clone rather than `remove`: two targets for the + // same address in one group must both resolve from the + // single probe. + match probes.get(&account.address).cloned() { + Some(Ok(proof)) => { + // Build an authoritative account update from the requested + // field mask. Use the MATERIALIZING `account_upsert` so a + // resync applies even to a cold account (a partial `Account` + // patch on a cold address is silently skipped). + let mut patch = AccountPatch::default(); + if account.fields.balance { + patch = patch.balance(proof.balance); + } + if account.fields.nonce { + patch = patch.nonce(proof.nonce); + } + // Note: `AccountProof` carries `code_hash`, not code bytes; + // the `eth_getProof` seam cannot supply runtime code, so a + // code-field resync is a no-op here (code freshness is + // handled by a later wave). We still materialize the account + // so requested balance/nonce fields take effect. + state_updates.push(StateUpdate::account_upsert(account.address, patch)); + } + Some(Err(error)) => { + failed.push(ResyncFailure { + request_id: account.request_id, + block: account.block, + target: ResyncTarget::Account { + address: account.address, + fields: account.fields, + }, + kind: ResyncFailureKind::AccountFetchFailed, + message: error.to_string(), + }); + } + None => { + failed.push(ResyncFailure { + request_id: account.request_id, + block: account.block, + target: ResyncTarget::Account { + address: account.address, + fields: account.fields, + }, + kind: ResyncFailureKind::AccountFetchOmitted, + message: + "account proof fetcher did not return a result for address" + .to_string(), + }); + } + } + } + } + } else { + for account in account_targets { + failed.push(ResyncFailure { + request_id: account.request_id, + block: account.block, + target: ResyncTarget::Account { + address: account.address, + fields: account.fields, + }, + kind: ResyncFailureKind::MissingAccountFetcher, + message: "account resync requires an account proof fetcher".to_string(), }); } - !request.targets.is_empty() - }); - cancelled + } } - /// Register a hook. - pub fn register_hook(&mut self, hook: Arc>) -> Result<(), RegisterError> { - self.hooks.push(hook); - Ok(()) - } + let diff = if state_updates.is_empty() { + StateDiff::default() + } else { + cache.apply_updates(&state_updates) + }; - /// Return all registered interests in handler registration order. - pub fn interests(&self) -> Vec> { - self.registry.interests() + ResyncReport { + requested: requests.to_vec(), + state_updates, + diff, + failed, } +} - /// Ingest a batch, apply valid direct state effects, and dispatch reports. - pub fn ingest_batch( - &mut self, - cache: &mut EvmCache, - batch: ReactiveInputBatch, - ) -> Result, ReactiveError> { - let batch_report = self.ingest_batch_direct(cache, batch)?; - self.dispatch_reports(&batch_report.reports); - let _ = &self.config; - Ok(batch_report) +fn push_resync_failures( + failed: &mut Vec, + block: &ResyncBlock, + origins: Vec, + kind: ResyncFailureKind, + message: String, +) { + for origin in origins { + failed.push(ResyncFailure { + request_id: origin.request_id, + block: block.clone(), + target: origin.target, + kind, + message: message.clone(), + }); } +} - /// Ingest a batch, then execute surfaced storage resync requests. - /// - /// This entrypoint preserves [`ingest_batch`](Self::ingest_batch) behavior for - /// direct handler effects, then runs a synchronous resync phase over the - /// collected [`ResyncRequest`]s. Storage targets are fetched through - /// [`EvmCache::storage_batch_fetcher`] grouped by [`ResyncBlock`], successful - /// values are applied as [`StateUpdate::slot`] updates through - /// [`EvmCache::apply_updates`], and unsupported or failed targets are reported - /// in [`ResyncReport::failed`]. It does not start subscribers, background - /// workers, or network transport. - pub fn ingest_batch_with_resync( - &mut self, - cache: &mut EvmCache, - batch: ReactiveInputBatch, - ) -> Result, ReactiveError> { - let mut batch_report = self.ingest_batch_direct(cache, batch)?; - - if !batch_report.resyncs.is_empty() { - let resync_report = execute_resync_requests(cache, &batch_report.resyncs); - // Count unique logical requests: several handlers may emit the same - // ResyncId in one batch, and duplicates fan out per-origin in the - // report but are one unit of resync work for the metric. - let unique_requests = resync_report - .requested - .iter() - .map(|request| &request.id) - .collect::>() - .len(); - self.metrics - .resync_requests - .fetch_add(unique_requests as u64, Ordering::Relaxed); - self.metrics - .resync_failures - .fetch_add(resync_report.failed.len() as u64, Ordering::Relaxed); - self.remove_pending_resyncs(batch_report.resyncs.iter().map(|request| &request.id)); - self.record_journal_resync(&resync_report); - batch_report - .reports - .push(Arc::new(ReactiveReport::Resynced(resync_report))); - } +fn push_storage_resync_slot( + groups: &mut Vec, + request_id: &ResyncId, + block: &ResyncBlock, + address: Address, + slot: U256, +) { + let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) { + index + } else { + groups.push(StorageFetchGroup { + block: block.clone(), + slots: Vec::new(), + seen: HashSet::new(), + }); + groups.len() - 1 + }; - self.dispatch_reports(&batch_report.reports); - let _ = &self.config; - Ok(batch_report) + let group = &mut groups[group_index]; + let origin = StorageFetchOrigin { + request_id: request_id.clone(), + target: ResyncTarget::StorageSlot { address, slot }, + }; + if group.seen.insert((address, slot)) { + group.slots.push(StorageFetchSlot { + address, + slot, + origins: vec![origin], + }); + } else if let Some(existing) = group + .slots + .iter_mut() + .find(|existing| existing.address == address && existing.slot == slot) + { + existing.origins.push(origin); } +} - fn ingest_batch_direct( - &mut self, - cache: &mut EvmCache, - batch: ReactiveInputBatch, - ) -> Result, ReactiveError> { - let records = sort_records(dedupe_records(batch.into_records())); - - let mut batch_report = ReactiveBatchReport::default(); - let mut reports_to_dispatch = Vec::new(); - // Phase-8 step 4: accumulate the addresses a decoder actually wrote this - // batch (union of applied `StateDiff` addresses) and the batch's canonical - // block number, so the per-block root gate can run once after the record - // loop with the full touched set. - let mut touched_addrs: HashSet
= HashSet::new(); - let mut canonical_batch_block: Option = None; - - for record in records { - let input_ref = record.input_ref(); - reports_to_dispatch.push(Arc::new(ReactiveReport::Input(InputReport { - input_ref, - context: record.context.clone(), - _network: PhantomData, - }))); +fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId { + match block { + ResyncBlock::Latest => BlockId::latest(), + ResyncBlock::Pending => BlockId::pending(), + ResyncBlock::Safe => BlockId::safe(), + ResyncBlock::Finalized => BlockId::finalized(), + ResyncBlock::Number(number) => BlockId::number(*number), + ResyncBlock::Hash { + number: _, + hash, + require_canonical, + } => BlockId::from((*hash, Some(*require_canonical))), + } +} - if let Some(reorg_report) = - self.recover_for_canonical_input(cache, &record, &mut reports_to_dispatch) - { - self.metrics - .reorgs_recovered - .fetch_add(1, Ordering::Relaxed); - remove_canceled_resyncs_from_batch( - &mut batch_report.resyncs, - &reorg_report.canceled_resyncs, - ); - reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report))); - } +impl RegisteredHandler { + fn matches(&self, input: &ReactiveInput) -> bool { + self.interests + .iter() + .any(|interest| interest_matches(interest, input)) + } - if let Some(reorg_report) = - self.recover_for_reorged_input(cache, &record, &mut reports_to_dispatch) - { - self.metrics - .reorgs_recovered - .fetch_add(1, Ordering::Relaxed); - remove_canceled_resyncs_from_batch( - &mut batch_report.resyncs, - &reorg_report.canceled_resyncs, - ); - reports_to_dispatch.push(Arc::new(ReactiveReport::Reorg(reorg_report))); - continue; - } + fn route_log(&self, log: &Log) -> Option { + self.interests.iter().find_map(|interest| match interest { + ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute { + handler_id: self.id.clone(), + route_key: interest.route_key(log), + }), + ReactiveInterest::Logs(_) + | ReactiveInterest::Blocks(_) + | ReactiveInterest::PendingTransactions(_) => None, + }) + } +} - if let Some(block) = canonical_record_block(&record) { - // Phase-8 step 4: remember the batch's canonical block (the last - // canonical record wins) so the root gate probes at that height. - canonical_batch_block = Some(block.number); - self.record_journal_input(block, input_ref); - } +fn merge_log_subscription_filter(filters: &mut Vec, next: &Filter) { + let mut candidate = next.clone(); + let mut insertion_index = filters.len(); + let mut index = 0; + while index < filters.len() { + if filters[index].block_option != candidate.block_option { + index += 1; + continue; + } + if let Some(merged) = exact_filter_union(&candidate, &filters[index]) { + candidate = merged; + insertion_index = insertion_index.min(index); + filters.remove(index); + index = 0; + } else { + index += 1; + } + } + filters.insert(insertion_index.min(filters.len()), candidate); +} - // Phase-8 step 2: drive a per-block env refresh from canonical - // headers. Best-effort — a strict validation failure is surfaced as - // a non-fatal error report and does not abort the batch. - if let Some(Err(err)) = advance_block_for_canonical_record(cache, &record) { - reports_to_dispatch.push(Arc::new(ReactiveReport::Error(ReactiveErrorReport { - input_ref: Some(input_ref), - message: err.to_string(), - _network: PhantomData, - }))); - } +fn exact_filter_union(left: &Filter, right: &Filter) -> Option { + if filter_subsumes(left, right) { + return Some(left.clone()); + } + if filter_subsumes(right, left) { + return Some(right.clone()); + } + let differing_dimensions = usize::from(left.address != right.address) + + left + .topics + .iter() + .zip(right.topics.iter()) + .filter(|(left, right)| left != right) + .count(); + if differing_dimensions != 1 { + return None; + } - let executions = self.execute_handlers(cache, &record, input_ref)?; - if executions.is_empty() { - continue; + let mut merged = left.clone(); + if merged.address != right.address { + merge_filter_set(&mut merged.address, &right.address); + } else { + for (merged_topic, right_topic) in merged.topics.iter_mut().zip(right.topics.iter()) { + if merged_topic != right_topic { + merge_filter_set(merged_topic, right_topic); + break; } + } + } + Some(merged) +} - reports_to_dispatch.push(Arc::new(ReactiveReport::Decoded(DecodedReport { - input_ref, - handler_ids: executions - .iter() - .map(|execution| execution.handler_id.clone()) - .collect(), - _network: PhantomData, - }))); +fn filter_subsumes(left: &Filter, right: &Filter) -> bool { + filter_set_subsumes(&left.address, &right.address) + && left + .topics + .iter() + .zip(right.topics.iter()) + .all(|(left, right)| filter_set_subsumes(left, right)) +} - detect_conflicts(input_ref, &executions)?; +fn filter_set_subsumes(left: &FilterSet, right: &FilterSet) -> bool { + left.is_empty() + || (!right.is_empty() + && right + .iter() + .all(|value| left.iter().any(|known| known == value))) +} - // Phase-8 step 3: canonical block number for freshness stamping. - // Copied out as a plain `u64` (dropping the borrow of `record`) so it - // can be used while `self.freshness_mut()` mutably borrows `self` - // inside the execution loop. `None` for pending/removed/reorged - // records — those never stamp canonical freshness. - let canonical_block_number = canonical_record_block(&record).map(|block| block.number); +fn merge_filter_set(target: &mut FilterSet, source: &FilterSet) { + if target.is_empty() { + return; + } + if source.is_empty() { + *target = FilterSet::default(); + return; + } + for value in source.iter() { + target.insert(value.clone()); + } +} - for execution in executions { - let diff = if execution.state_updates.is_empty() { - StateDiff::default() - } else { - cache.apply_updates(&execution.state_updates) - }; +#[derive(Clone, Debug)] +struct HandlerExecution { + handler_id: HandlerId, + quality: StateEffectQuality, + tags: Vec, + state_updates: Vec, + invalidations: Vec, + resyncs: Vec, + speculative: Vec, + hook_signals: Vec, +} - batch_report - .resyncs - .extend(execution.resyncs.iter().cloned()); - self.pending_resyncs - .extend(execution.resyncs.iter().cloned()); - batch_report - .speculative - .extend(execution.speculative.iter().cloned()); +impl HandlerExecution { + fn from_outcome( + handler_id: HandlerId, + input_ref: InputRef, + outcome: HandlerOutcome, + preconfirmed: bool, + ) -> Self { + let mut state_updates = Vec::new(); + let mut invalidations = Vec::new(); + let mut resyncs = Vec::new(); + let mut speculative = Vec::new(); + let mut hook_signals = Vec::new(); - let applied = AppliedReport { - input_ref, - handler_id: execution.handler_id, - quality: execution.quality, - tags: execution.tags, - diff, - state_updates: execution.state_updates, - invalidations: execution.invalidations, - resyncs: execution.resyncs, - speculative: execution.speculative, - hook_signals: execution.hook_signals, - _network: PhantomData, - }; - // Phase-8 step 3 (opt-in): stamp every touched `(address, slot)` - // from this canonical handler write as `ValidThrough(N)`, so an - // event-maintained slot stops being re-verified until the clock - // passes its write block. Read the changed slots straight off - // `applied.diff` (which borrows the local, not `self`) and stamp - // via `self.freshness`, done before `applied` is moved into the - // journal/batch below. Only genuinely-changed slots appear here, - // since a no-op re-write records no `SlotChange`. - if let (Some(number), Some(registry)) = - (canonical_block_number, self.freshness.as_mut()) - { - for change in &applied.diff.slots { - registry.valid_through_slot(change.address, change.slot, number); + for effect in outcome.effects { + match effect { + ReactiveEffect::StateUpdate(update) => state_updates.push(update), + ReactiveEffect::Invalidate(invalidation) => { + state_updates.push(StateUpdate::purge( + invalidation.address, + invalidation.scope.clone(), + )); + invalidations.push(invalidation); + } + ReactiveEffect::Resync(mut request) => { + if preconfirmed { + request.block = ResyncBlock::Pending; } + resyncs.push(request); } - - // Phase-8 step 4: record every address this decoder actually wrote - // (or attempted to write) so the root gate can tell a - // decoder-covered root move from an uncovered coverage gap. Fold in - // the full `StateDiff` address footprint — real changes - // (`slots`/`accounts`/`purged`) and cold-skipped attempts alike, so - // a decoder that tried to write a cold slot still counts as - // covering the account. - collect_diff_addresses(&applied.diff, &mut touched_addrs); - - let report = Arc::new(ReactiveReport::Applied(applied.clone())); - reports_to_dispatch.push(report); - if let Some(block) = canonical_record_block(&record) { - self.record_journal_applied(block, applied.clone()); + ReactiveEffect::Hook(signal) => hook_signals.push(signal), + ReactiveEffect::Speculative(mut request) => { + request.input_ref = input_ref; + speculative.push(request); } - batch_report.applied.push(applied); } } - // Phase-8 step 4 + §6.2 cadence: accumulate this batch's touched - // addresses (after all handler effects, so the set is complete), then - // fire the root gate only on cadence boundaries. The gate diffs - // against persisted baselines, so skipped blocks lose no detection — - // but the touched set must be the union since the last firing, or a - // decoder-covered write in a skipped block would false-positive as a - // CoverageGap. Fired resyncs surface in `batch_report.resyncs` (so - // callers see them and `ingest_batch_with_resync` executes them) and - // coverage reports go into the dispatched reports. - if self.root_gate_runnable(cache) { - self.touched_since_gate - .extend(touched_addrs.iter().copied()); - if self.root_gate_due(canonical_batch_block) { - let accumulated = std::mem::take(&mut self.touched_since_gate); - self.run_root_gate( - cache, - canonical_batch_block, - &accumulated, - &mut batch_report.resyncs, - &mut reports_to_dispatch, - ); - self.last_gate_block = canonical_batch_block; - } + Self { + handler_id, + quality: outcome.quality, + tags: outcome.tags, + state_updates, + invalidations, + resyncs, + speculative, + hook_signals, + } + } +} + +fn dedupe_records( + records: Vec>, +) -> Result>, ReactiveError> { + let mut positions = HashMap::::new(); + let mut deduped = Vec::with_capacity(records.len()); + for record in records { + let identity = record.validated_identity()?; + if !record.is_payload_deduplicable() { + deduped.push(record); + continue; + } + if let Some(index) = positions.get(&identity).copied() { + let merged = deduped[index].merge_compatible_duplicate(&record)?; + debug_assert!(merged, "same indexed identity is deduplicable"); } else { - // A gate that cannot run (disabled, nothing root-gated, or no - // proof fetcher) must not grow the accumulator unboundedly. - // Dropping it is safe: without a runnable gate no baselines exist - // (a fetcher cannot be uninstalled, and untracking drops the - // baseline), so there is nothing a lost touched set could falsely - // gap against later. - self.touched_since_gate.clear(); + positions.insert(identity, deduped.len()); + deduped.push(record); } + } + Ok(deduped) +} - batch_report.reports = reports_to_dispatch; - Ok(batch_report) +fn dedupe_scoped_records( + records: Vec<(ReactiveInputRecord, DeliveryAudience, DeliveryScope)>, +) -> Result, DeliveryAudience, DeliveryScope)>, ReactiveError> { + let mut positions: HashMap = HashMap::new(); + let mut deduped: Vec<(ReactiveInputRecord, DeliveryAudience, DeliveryScope)> = + Vec::with_capacity(records.len()); + for (record, audience, delivery_scope) in records { + let identity = record.validated_identity()?; + if !record.is_payload_deduplicable() { + deduped.push((record, audience, delivery_scope)); + continue; + } + if let Some(index) = positions.get(&identity).copied() { + let merged = deduped[index].0.merge_compatible_duplicate(&record)?; + debug_assert!(merged, "same indexed identity is deduplicable"); + merge_delivery_audience(&mut deduped[index].1, audience); + merge_delivery_scope(&mut deduped[index].2, delivery_scope); + } else { + positions.insert(identity, deduped.len()); + deduped.push((record, audience, delivery_scope)); + } + } + Ok(deduped) +} + +fn merge_delivery_scope(into: &mut DeliveryScope, incoming: DeliveryScope) { + *into = match (*into, incoming) { + (DeliveryScope::Canonical, _) | (_, DeliveryScope::Canonical) => DeliveryScope::Canonical, + (DeliveryScope::CanonicalProgress, _) | (_, DeliveryScope::CanonicalProgress) => { + DeliveryScope::CanonicalProgress + } + (DeliveryScope::Preconfirmed, DeliveryScope::Preconfirmed) + | (DeliveryScope::Preconfirmed, DeliveryScope::OwnerCatchup) + | (DeliveryScope::OwnerCatchup, DeliveryScope::Preconfirmed) => DeliveryScope::Preconfirmed, + (DeliveryScope::OwnerCatchup, DeliveryScope::OwnerCatchup) => DeliveryScope::OwnerCatchup, + }; +} + +fn merge_delivery_audience(into: &mut DeliveryAudience, incoming: DeliveryAudience) { + match (&mut *into, incoming) { + (DeliveryAudience::All, _) => {} + (current, DeliveryAudience::All) => *current = DeliveryAudience::All, + (DeliveryAudience::Owners(current), DeliveryAudience::Owners(incoming)) => { + for owner in incoming { + if !current.contains(&owner) { + current.push(owner); + } + } + } + (DeliveryAudience::AllExcept(current), DeliveryAudience::AllExcept(incoming)) => { + current.retain(|owner| incoming.contains(owner)); + } + (DeliveryAudience::AllExcept(excluded), DeliveryAudience::Owners(included)) => { + excluded.retain(|owner| !included.contains(owner)); + } + (current @ DeliveryAudience::Owners(_), DeliveryAudience::AllExcept(mut excluded)) => { + let DeliveryAudience::Owners(included) = current else { + unreachable!("match arm restricts the audience variant") + }; + excluded.retain(|owner| !included.contains(owner)); + *current = DeliveryAudience::AllExcept(excluded); + } + } +} + +fn sort_records(records: Vec>) -> Vec> { + let mut indexed: Vec<(usize, ReactiveInputRecord)> = + records.into_iter().enumerate().collect(); + indexed.sort_by_key(|(index, record)| record_sort_key(*index, record)); + indexed.into_iter().map(|(_, record)| record).collect() +} + +fn sort_scoped_records( + records: Vec<(ReactiveInputRecord, DeliveryAudience, DeliveryScope)>, +) -> Vec<(ReactiveInputRecord, DeliveryAudience, DeliveryScope)> { + let mut indexed: Vec<_> = records.into_iter().enumerate().collect(); + indexed.sort_by_key(|(index, (record, _, _))| record_sort_key(*index, record)); + indexed + .into_iter() + .map(|(_, scoped_record)| scoped_record) + .collect() +} + +fn record_sort_key(index: usize, record: &ReactiveInputRecord) -> RecordSortKey { + if let Some((block, _)) = reorg_signal_block(record) { + return RecordSortKey { + class: 0, + block_number: block.number, + record_class: 0, + transaction_index: record.context.transaction_index.unwrap_or(u64::MAX), + log_index: record.context.log_index.unwrap_or(u64::MAX), + original_index: index, + }; + } + if is_canonical_status(&record.context.chain_status) + && let Some(block) = record.context.block.as_ref() + { + let (record_class, transaction_index, log_index) = match &record.input { + ReactiveInput::BlockHeader(_) | ReactiveInput::FullBlock(_) => (0, 0, 0), + ReactiveInput::Log(log) if !log.removed => ( + 1, + log.transaction_index + .or(record.context.transaction_index) + .unwrap_or(u64::MAX), + log.log_index + .or(record.context.log_index) + .unwrap_or(u64::MAX), + ), + ReactiveInput::Log(_) + | ReactiveInput::PendingTxHash(_) + | ReactiveInput::PendingTx(_) => (2, u64::MAX, u64::MAX), + }; + return RecordSortKey { + class: 1, + block_number: block.number, + record_class, + transaction_index, + log_index, + original_index: index, + }; + } + + RecordSortKey { + class: 2, + block_number: 0, + record_class: 0, + transaction_index: 0, + log_index: 0, + original_index: index, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct RecordSortKey { + class: u8, + block_number: u64, + record_class: u8, + transaction_index: u64, + log_index: u64, + original_index: usize, +} + +fn interest_matches(interest: &ReactiveInterest, input: &ReactiveInput) -> bool { + match (interest, input) { + (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log), + ( + ReactiveInterest::Blocks(BlockInterest { + mode: BlockInterestMode::Header, + }), + ReactiveInput::BlockHeader(_), + ) => true, + ( + ReactiveInterest::Blocks(BlockInterest { + mode: BlockInterestMode::FullBlock, + }), + ReactiveInput::FullBlock(_), + ) => true, + (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => { + interest.matches_hash_only() + } + (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => { + interest.matches_tx(tx) + } + _ => false, } +} - /// Whether the root gate could produce any signal at all: some tracked - /// account is root-gated (`Slots` never is) and a proof fetcher exists. - /// When this is false the touched accumulator is dropped rather than - /// grown (see the ingest call site for why that is safe). - fn root_gate_runnable(&self, cache: &EvmCache) -> bool { - if matches!(self.root_gate_cadence, RootGateCadence::Disabled) { - return false; - } - let has_gated_targets = self - .tracking - .values() - .any(|policy| !matches!(policy, TrackingPolicy::Slots { .. })); - has_gated_targets && cache.account_proof_fetcher().is_some() +fn validate_effects( + input_ref: InputRef, + ctx: &ReactiveContext, + handler_id: &HandlerId, + effects: &[ReactiveEffect], +) -> Result<(), ReactiveError> { + let pending = matches!(ctx.chain_status, ChainStatus::Pending) + || matches!(input_ref, InputRef::PendingTx { .. }); + if !pending { + return Ok(()); } - /// Whether the root gate is due at this batch's canonical block (§6.2): - /// the first canonical block ever seen always fires (baseline adoption - /// must not wait a full window), then at most once every `n` blocks. - fn root_gate_due(&self, canonical_block: Option) -> bool { - let Some(block) = canonical_block else { - return false; + for effect in effects { + let effect_kind = match effect { + ReactiveEffect::StateUpdate(_) => Some("state_update"), + ReactiveEffect::Invalidate(_) => Some("invalidate"), + ReactiveEffect::Resync(_) => Some("resync"), + ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None, }; - match self.root_gate_cadence { - RootGateCadence::Disabled => false, - RootGateCadence::EveryNBlocks(n) => match self.last_gate_block { - None => true, - Some(last) => block >= last.saturating_add(n.get()), - }, + if let Some(effect_kind) = effect_kind { + return Err(ReactiveError::InvalidPendingEffect { + input_ref: Box::new(input_ref), + handler_id: handler_id.clone(), + effect_kind, + }); } } + Ok(()) +} - /// The `storageHash` root gate (Phase-8 step 4), fired per - /// [`RootGateCadence`] window (§6.2). - /// - /// Runs at the firing batch's canonical block, with `touched` carrying the - /// union of decoder-touched addresses since the previous firing. For each tracked - /// [`WholeAccount`](TrackingPolicy::WholeAccount) / [`Scalars`](TrackingPolicy::Scalars) - /// account, probe the root (and account fields) via the account-proof seam and - /// apply the spec §4 table: - /// - /// - No baseline yet ⇒ **adopt** (no gap, no resync — adoption is not a gap). - /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root unchanged ⇒ nothing. - /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∈ touched` - /// ⇒ a decoder covered it; re-adopt, no gap. - /// - [`WholeAccount`](TrackingPolicy::WholeAccount) root moved, `addr ∉ touched` - /// ⇒ emit [`ReactiveReport::CoverageGap`], count it, schedule a - /// [`ResyncReason::RootMoved`] account resync, re-adopt. - /// - [`Scalars`](TrackingPolicy::Scalars) ⇒ compare balance/nonce/code-hash to - /// the baseline (native field changes never move the storage root); on a move - /// with `addr ∉ touched`, schedule a [`ResyncReason::RootMoved`] account - /// resync for the changed fields and re-adopt. - /// - /// No-op when the tracking registry is empty, when the batch has no canonical - /// block, or when the cache has no account-proof fetcher installed. - /// [`Slots`](TrackingPolicy::Slots) accounts are never root-gated (spec - /// Decision 3). - fn run_root_gate( - &mut self, - cache: &EvmCache, - canonical_block: Option, - touched: &HashSet
, - resyncs: &mut Vec, - reports: &mut Vec>>, - ) { - if self.tracking.is_empty() { - return; +fn detect_conflicts( + input_ref: InputRef, + executions: &[HandlerExecution], +) -> Result<(), ReactiveError> { + let mut writes: HashMap = HashMap::new(); + for execution in executions { + for update in &execution.state_updates { + for (target, value) in absolute_writes(update) { + if let Some((previous_value, previous_handler)) = writes.get(&target) { + if previous_value != &value { + return Err(ReactiveError::ConflictingEffects { + input_ref: Box::new(input_ref), + target: Box::new(target), + first: previous_handler.clone(), + second: execution.handler_id.clone(), + }); + } + } else { + writes.insert(target, (value, execution.handler_id.clone())); + } + } } - let Some(block) = canonical_block else { - return; - }; - let Some(fetcher) = cache.account_proof_fetcher().cloned() else { - return; - }; + } + Ok(()) +} - // Collect the root-gated targets (Slots opts out) in a stable order so a - // single-block sequence of resyncs/reports is deterministic. - let mut targets: Vec<(Address, bool)> = self - .tracking - .iter() - .filter_map(|(address, policy)| match policy { - TrackingPolicy::Slots { .. } => None, - TrackingPolicy::WholeAccount => Some((*address, true)), - TrackingPolicy::Scalars => Some((*address, false)), - }) - .collect(); - if targets.is_empty() { - return; +fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> { + match update { + StateUpdate::Slot { + address, + slot, + value, + } => vec![( + EffectTarget::StorageSlot { + address: *address, + slot: *slot, + }, + AbsoluteValue::U256(*value), + )], + StateUpdate::SlotMasked { + address, + slot, + mask, + value, + } => vec![( + EffectTarget::MaskedStorageSlot { + address: *address, + slot: *slot, + mask: *mask, + }, + AbsoluteValue::U256(*value), + )], + StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => { + account_patch_writes(*address, patch) } - targets.sort_by_key(|(address, _)| *address); + StateUpdate::SlotDelta { .. } + | StateUpdate::BalanceDelta { .. } + | StateUpdate::Purge { .. } => Vec::new(), + } +} - let block_id = BlockId::number(block); - // ONE seam invocation carries every root-gated target (root-only - // probes: no storage keys needed). eth_getProof is single-address at - // the RPC level, so batching here lets the fetcher fan the requests - // out concurrently instead of paying N sequential round trips. - let mut probes: HashMap> = (fetcher)( - targets - .iter() - .map(|&(address, _)| (address, vec![])) - .collect(), - block_id, - ) - .into_iter() - .collect(); - for (address, whole_account) in targets { - let Some(Ok(proof)) = probes.remove(&address) else { - // A failed/omitted probe carries no signal; leave the baseline - // untouched and try again next block. - continue; - }; +fn account_patch_writes( + address: Address, + patch: &AccountPatch, +) -> Vec<(EffectTarget, AbsoluteValue)> { + let mut writes = Vec::new(); + if let Some(balance) = patch.balance { + writes.push(( + EffectTarget::AccountBalance { address }, + AbsoluteValue::U256(balance), + )); + } + if let Some(nonce) = patch.nonce { + writes.push(( + EffectTarget::AccountNonce { address }, + AbsoluteValue::U64(nonce), + )); + } + if let Some(code) = &patch.code { + writes.push(( + EffectTarget::AccountCode { address }, + AbsoluteValue::Bytes(code.clone()), + )); + } + writes +} - let baseline = self.tracked_roots.get(&address).cloned(); - let Some(baseline) = baseline else { - // First observation: adopt the baseline. Not a coverage gap. - self.adopt_root(address, block, &proof); - continue; - }; +fn input_ref(input: &ReactiveInput, ctx: &ReactiveContext) -> InputRef { + match input { + ReactiveInput::Log(log) => InputRef::Log { + chain_id: ctx.chain_id, + block_hash: log + .block_hash + .or(ctx.block.as_ref().map(|block| block.hash)) + .unwrap_or_default(), + transaction_hash: log.transaction_hash.unwrap_or_default(), + log_index: log.log_index.or(ctx.log_index).unwrap_or_default(), + }, + ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx { + chain_id: ctx.chain_id, + hash: *hash, + }, + ReactiveInput::PendingTx(tx) => InputRef::PendingTx { + chain_id: ctx.chain_id, + hash: tx.tx_hash(), + }, + ReactiveInput::BlockHeader(header) => InputRef::Block { + chain_id: ctx.chain_id, + hash: header.hash(), + number: header.number(), + }, + ReactiveInput::FullBlock(block) => { + let header = block.header(); + InputRef::Block { + chain_id: ctx.chain_id, + hash: header.hash(), + number: header.number(), + } + } + } +} + +fn is_canonical_status(status: &ChainStatus) -> bool { + matches!( + status, + ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. } + ) +} - // A stale probe (a batch whose canonical block is not newer than the - // last one we baselined this account against) carries no forward - // signal: skip it rather than diff against — or clobber — a newer - // baseline. - if block <= baseline.last_block { - continue; - } +/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler. +pub struct EventDecoderHandler { + id: HandlerId, + decoder: Arc, + interest: LogInterest, +} - if whole_account { - if proof.storage_hash == baseline.last_root { - // Tight steady-state path: unchanged root ⇒ nothing. - continue; - } - // Root moved. - if !touched.contains(&address) { - // Moved with no covering decoder — the coverage gap. - reports.push(Arc::new(ReactiveReport::CoverageGap(CoverageGapReport { - address, - block, - _network: PhantomData, - }))); - self.metrics.coverage_gaps.fetch_add(1, Ordering::Relaxed); - resyncs.push(root_moved_account_resync( - address, - block, - AccountFieldMask { - balance: true, - nonce: true, - code: true, - }, - )); - } - // Adopt the new root whether or not a decoder covered it. - self.adopt_root(address, block, &proof); - } else { - // Scalars: compare the account fields directly (native changes do - // not move the storage root). - let balance_moved = proof.balance != baseline.balance; - let nonce_moved = proof.nonce != baseline.nonce; - let code_moved = proof.code_hash != baseline.code_hash; - if (balance_moved || nonce_moved || code_moved) && !touched.contains(&address) { - resyncs.push(root_moved_account_resync( - address, - block, - AccountFieldMask { - balance: balance_moved, - nonce: nonce_moved, - code: code_moved, - }, - )); - } - self.adopt_root(address, block, &proof); - } +impl EventDecoderHandler { + /// Create an adapter from a decoder and log interest. + pub fn new(id: HandlerId, decoder: Arc, interest: LogInterest) -> Self { + Self { + id, + decoder, + interest, } } +} - /// Adopt (or re-adopt) `proof` as the baseline for `address` at `block`. - fn adopt_root(&mut self, address: Address, block: u64, proof: &AccountProof) { - self.tracked_roots.insert( - address, - TrackedRoot { - last_root: proof.storage_hash, - last_block: block, - balance: proof.balance, - nonce: proof.nonce, - code_hash: proof.code_hash, - }, - ); +impl ReactiveHandler for EventDecoderHandler { + fn id(&self) -> HandlerId { + self.id.clone() } - fn execute_handlers( + fn interests(&self) -> Vec> { + vec![ReactiveInterest::Logs(self.interest.clone())] + } + + fn handle( &self, - cache: &EvmCache, - record: &ReactiveInputRecord, - input_ref: InputRef, - ) -> Result, ReactiveError> { - let mut executions = Vec::new(); - let candidates: Vec<_> = match &record.input { - ReactiveInput::Log(log) => self.registry.log_handler_candidates(log), - ReactiveInput::BlockHeader(_) - | ReactiveInput::FullBlock(_) - | ReactiveInput::PendingTxHash(_) - | ReactiveInput::PendingTx(_) => self.registry.handlers().collect(), + _ctx: &ReactiveContext, + input: &ReactiveInput, + state: &dyn StateView, + ) -> Result { + let ReactiveInput::Log(log) = input else { + return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect)); }; - for registered in candidates { - if !registered.matches(&record.input) { - continue; - } - let outcome = registered - .handler - .handle(&record.context, &record.input, cache) - .map_err(|source| ReactiveError::HandlerFailed { - handler_id: registered.id.clone(), - source, - })?; + Ok(HandlerOutcome { + effects: self + .decoder + .decode(&log.inner, state) + .into_iter() + .map(ReactiveEffect::StateUpdate) + .collect(), + quality: StateEffectQuality::ExactFromInput, + tags: Vec::new(), + }) + } +} - if let Err(error) = - validate_effects(input_ref, &record.context, ®istered.id, &outcome.effects) - { - if matches!(error, ReactiveError::InvalidPendingEffect { .. }) { - self.metrics - .pending_contamination - .fetch_add(1, Ordering::Relaxed); - } - return Err(error); - } - executions.push(HandlerExecution::from_outcome( - registered.id.clone(), - input_ref, - outcome, - )); +/// One independently negotiable event-subscriber behavior. +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[non_exhaustive] +pub enum SubscriberCapability { + /// Emit EVM logs. + Logs, + /// Emit block headers. + BlockHeaders, + /// Emit full blocks with transaction bodies. + FullBlocks, + /// Emit pending transaction hashes. + PendingTransactionHashes, + /// Emit hydrated pending transactions. + PendingTransactions, + /// Fetch historical data from a caller-selected anchor. + HistoricalBackfill, + /// Follow live chain data. + Live, + /// Recover the complete committed consumer position after reconnect or + /// restart, including any unacknowledged delivery. + /// + /// An implementation may satisfy this with native stream replay or with a + /// durable cursor plus deterministic historical reconciliation of an + /// ephemeral live child. The end-to-end subscriber must still prove there + /// is no gap between the restored position and resumed live delivery. If an + /// old delivery token is emitted again, that token must identify the same + /// immutable delivery and pass the engine's witness check. + DurableReplay, + /// Preserve logical handler ownership on delivered batches. + OwnerScopedDelivery, + /// Add and remove interests without replacing the complete session. + DynamicInterests, + /// Emit explicit canonical branch transitions. + ExplicitReorgs, + /// Emit safe and finalized head updates. + FinalityUpdates, + /// Emit ordered synchronization or source-cutover barriers. + Barriers, + /// Emit sequencer pre-confirmations into a disposable state overlay. + Preconfirmations, +} + +/// Capability set advertised by an [`EventSubscriber`]. +/// +/// The default is deliberately empty: callers can safely reject a topology +/// when an older or minimal implementation has not opted into a required +/// behavior. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SubscriberCapabilities { + supported: BTreeSet, +} + +impl SubscriberCapabilities { + /// Construct a capability set from supported behaviors. + pub fn new(capabilities: impl IntoIterator) -> Self { + Self { + supported: capabilities.into_iter().collect(), } - Ok(executions) } - fn dispatch_reports(&self, reports: &[Arc>]) { - for report in reports { - for hook in &self.hooks { - hook.on_report(report.clone()); - } - } + /// Test one independently negotiable behavior. + pub fn supports(&self, capability: SubscriberCapability) -> bool { + self.supported.contains(&capability) } - fn recover_for_canonical_input( - &mut self, - cache: &mut EvmCache, - record: &ReactiveInputRecord, - health_reports: &mut Vec>>, - ) -> Option> { - let block = canonical_record_block(record)?; - let latest = self.journal.back()?.block.clone(); + /// Iterate supported behaviors in stable order. + pub fn iter(&self) -> impl Iterator + '_ { + self.supported.iter().copied() + } - if self - .journal - .iter() - .any(|entry| entry.block.hash == block.hash && entry.block.number == block.number) - { - return None; - } + /// Whether the subscriber follows live chain data. + pub fn supports_live(&self) -> bool { + self.supports(SubscriberCapability::Live) + } - if block.number == latest.number.saturating_add(1) && block.parent_hash == Some(latest.hash) - { - return None; - } + /// Whether the subscriber can durably recover its committed position and + /// any unacknowledged delivery without an event gap. + pub fn supports_durable_replay(&self) -> bool { + self.supports(SubscriberCapability::DurableReplay) + } - if block.number > latest.number.saturating_add(1) { - // A forward gap: blocks between the journaled head and the arriving - // block were never observed (e.g. a disconnect). Make it observable - // and escalate health, but still accept the arriving block so it - // journals/applies normally (the chain extends). - self.metrics.missed_ranges.fetch_add(1, Ordering::Relaxed); - health_reports.extend(self.escalate_trust(block.number)); - health_reports.push(Arc::new(ReactiveReport::MissedBlockRange( - MissedRangeReport { - from: latest.number + 1, - to: block.number - 1, - block: block.number, - _network: PhantomData, - }, - ))); - return None; - } + /// Whether the subscriber emits explicit branch transitions. + pub fn supports_explicit_reorgs(&self) -> bool { + self.supports(SubscriberCapability::ExplicitReorgs) + } +} - let dropped = if let Some(parent_hash) = block.parent_hash { - if let Some(parent_index) = self - .journal - .iter() - .rposition(|entry| entry.block.hash == parent_hash) - { - self.drain_journal_after(parent_index) - } else { - health_reports.extend(self.warn_under_recovery(block.number)); - self.drain_journal_from_number(block.number) - } - } else { - health_reports.extend(self.warn_under_recovery(block.number)); - self.drain_journal_from_number(block.number) - }; +impl FromIterator for SubscriberCapabilities { + fn from_iter>(iter: T) -> Self { + Self::new(iter) + } +} + +/// Provider-agnostic subscriber interface. +pub trait EventSubscriber: Send { + /// Chain identity attached to emitted records, when it has been resolved. + /// + /// Remote and provider-backed subscribers should cache one authoritative + /// identity before exposing input. Returning `None` is reserved for + /// synthetic or genuinely chain-agnostic subscribers; composite sources + /// can use this hook to reject accidentally mixed networks. + fn chain_id(&self) -> Option { + None + } - self.recover_dropped_journals(cache, dropped, ReorgReason::ParentMismatch) + /// Behaviors this subscriber can uphold for topology validation. + fn capabilities(&self) -> SubscriberCapabilities { + SubscriberCapabilities::default() } - fn recover_for_reorged_input( + /// Replace all interests registered with the subscriber. + /// + /// Implementations may use this as a full setup/reset operation. The + /// in-crate [`AlloySubscriber`] clears owner-scoped interest state and + /// delivery/dedupe bookkeeping when this method is called. + /// + /// The returned operation must complete only after the replacement has + /// committed to the subscriber's desired state. Remote implementations can + /// use this asynchronous boundary to wait for an authoritative service-side + /// acknowledgement before returning `Ok(())`. On error, or when the future + /// is dropped before completion, the previously committed desired state + /// must remain authoritative (or be reconciled before later delivery can + /// expose the uncommitted change) so callers can safely retry. + /// + /// # Errors + /// + /// The returned operation reports [`SubscriberError`] when the replacement + /// cannot be validated or committed by the underlying source. + fn register_interests( &mut self, - cache: &mut EvmCache, - record: &ReactiveInputRecord, - health_reports: &mut Vec>>, - ) -> Option> { - let (dropped_block, reason) = reorg_signal_block(record)?; - let dropped = if let Some(index) = self - .journal - .iter() - .position(|entry| entry.block.hash == dropped_block.hash) - { - self.drain_journal_from(index) - } else { - health_reports.extend(self.warn_under_recovery(dropped_block.number)); - self.drain_journal_from_number(dropped_block.number) - }; + interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()>; - if dropped.is_empty() { - let canceled_resyncs = - self.cancel_resyncs_for_dropped_blocks(std::slice::from_ref(&dropped_block)); - if canceled_resyncs.is_empty() { - return None; - } - return Some(ReorgReport { - dropped: Some(dropped_block.clone()), - dropped_blocks: vec![dropped_block], - dropped_inputs: Vec::new(), - rollback_updates: Vec::new(), - rollback_diff: StateDiff::default(), - purge_updates: Vec::new(), - purge_diff: StateDiff::default(), - canceled_resyncs, - reason, - _network: PhantomData, - }); - } + /// Return the next input batch, or `Ok(None)` when the stream is exhausted. + /// + /// The returned future must be cancellation-safe: dropping it while pending + /// must not discard a complete input that a later call could otherwise + /// deliver. Composite subscribers use this property to race historical and + /// live sources without dedicating a task to each transport. + /// + /// # Errors + /// + /// The returned future reports [`SubscriberError`] for transport, + /// continuity, decoding, or source-resource failures. + fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>; + + /// Restore the subscriber's committed position before polling resumes. + /// + /// The engine invokes this synchronously from + /// [`ReactiveEngine::resume_from_durable_checkpoint`] after decoding runtime + /// recovery state and before publishing that state as resumed. Implementations + /// should validate that provider/service cursors cannot regress and seed any + /// source epoch or overlap history required for safe replay. A composite may + /// rebuild an ephemeral live child from `coverage_head` plus historical + /// reconciliation rather than require that child to replay bytes itself, but + /// it may advertise [`SubscriberCapability::DurableReplay`] only when the + /// complete restore closes that cutover gap before exposing live input. On + /// error, either + /// the prior position must remain authoritative, or the subscriber may retain + /// this *exact* restore as pending intent; in the latter case it must block + /// delivery and reject conflicting restores until retry/reconciliation commits + /// the same position. This permits synchronous adapters over durable remote + /// state without exposing a half-restored stream. + /// + /// # Errors + /// + /// Returns [`SubscriberError`] when the position is invalid, regresses or + /// conflicts with committed source state, or cannot be restored durably. + fn restore_position( + &mut self, + _position: &SubscriberResumePosition, + ) -> Result<(), SubscriberError> { + Ok(()) + } - self.recover_dropped_journals(cache, dropped, reason) + /// Commit a subscriber-owned delivery token after runtime ingestion. + /// + /// Ephemeral subscribers can rely on this no-op default. Durable remote + /// subscribers should make acknowledgement idempotent because cancellation + /// or transport failure can cause a successfully ingested batch to replay. + /// Re-emitting a token must reproduce the same immutable records, routing, + /// chain controls, chain identity, and provider checkpoint; the checkpointed + /// engine verifies its persisted delivery witness before skipping ingestion. + /// + /// # Errors + /// + /// The returned operation reports [`SubscriberError`] when the delivery + /// token cannot be committed idempotently by the source. + fn acknowledge_delivery( + &mut self, + _token: SubscriberDeliveryToken, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { Ok(()) }) } +} - /// Warn that a reorg references a block no longer resident in the journal, so - /// recovery is limited to the blocks still journaled — effects from aged-out - /// blocks are neither rolled back nor purged (the freshness/validation loop is - /// the backstop). Makes the under-recovery observable instead of silent. +/// Boxed, sendable future returned by subscriber lifecycle operations. +/// +/// The output is generic so the same type can represent registration, removal, +/// and future acknowledgement values without requiring an async-trait helper. +pub type SubscriberOperation<'a, T> = + Pin> + Send + 'a>>; + +/// Boxed future returned by [`EventSubscriber::next_batch`]. +pub type SubscriberNextBatch<'a, N> = Pin< + Box>, SubscriberError>> + Send + 'a>, +>; + +/// Boxed future returned by [`AlloySubscriber::next_scoped_batch`]. +pub type SubscriberNextScopedBatch<'a, N> = Pin< + Box>, SubscriberError>> + Send + 'a>, +>; + +/// Subscriber mode requested for the Alloy subscriber. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum SubscriberMode { + /// Prefer the default compiled transport. /// - /// This is a deep reorg: it increments the `deep_reorgs` counter and escalates - /// health along the trust-loss ladder via [`escalate_trust`](Self::escalate_trust) - /// (a first event degrades to [`CacheHealth::Degraded`], a second escalates to - /// [`CacheHealth::Unhealthy`]). Any resulting [`ReactiveReport::Health`] - /// transition is returned so the caller can thread it into the ingest cycle's - /// dispatched reports. - fn warn_under_recovery(&mut self, reorg_number: u64) -> Option>> { - let oldest_journaled = self.journal.front().map(|entry| entry.block.number); - tracing::warn!( - reorg_block = reorg_number, - oldest_journaled = ?oldest_journaled, - journal_depth = self.config.journal_depth, - "reactive reorg recovery is incomplete: the reorged block is no longer \ - in the journal, so effects from blocks aged out of the journal are \ - neither rolled back nor purged (the freshness/validation loop is the \ - backstop). Increase ReactiveConfig::journal_depth to recover deeper \ - reorgs precisely." - ); + /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket + /// subscriptions. Without `reactive-ws`, it resolves to polling only when + /// the opt-in `reactive-polling` feature is enabled. + #[default] + Auto, + /// Use provider pubsub streams. + PubSub, + /// Use polling/watch APIs. Requires the `reactive-polling` feature. + Polling, +} - self.metrics.deep_reorgs.fetch_add(1, Ordering::Relaxed); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FlashblocksAdapter { + BaseNative, + OpPending, +} - self.escalate_trust(reorg_number) +fn flashblocks_adapter(chain_id: u64) -> Option { + match chain_id { + 8_453 | 84_532 => Some(FlashblocksAdapter::BaseNative), + 10 | 11_155_420 => Some(FlashblocksAdapter::OpPending), + _ => None, } +} - fn record_journal_input(&mut self, block: &BlockRef, input_ref: InputRef) { - let entry = self.journal_entry_mut(block); - if !entry.inputs.contains(&input_ref) { - entry.inputs.push(input_ref); +/// Subscriber configuration. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubscriberConfig { + /// Flashblocks delivery policy. Provider support itself is configured by + /// the transport's single `flashblocks` endpoint flag. + pub preconfirmations: PreconfirmationMode, + /// OP pending-state sampling cadence. Base uses native `newFlashblocks` + /// plus `pendingLogs` subscriptions instead. + pub flashblock_poll_interval: Duration, + /// Hydrate pending transaction hashes into full bodies when possible. + pub hydrate_pending_transactions: bool, + /// Verify each canonical log's block identity through RPC and enrich its + /// context with the exact parent hash before delivery. + /// + /// Enable this when a strict coordinator (such as a hybrid historical/live + /// source) must prove canonical ancestry from log-only pubsub events. + /// Verification is cached per block, so the provider is queried at most + /// once for each distinct canonical block retained in the dedupe window. + /// For high-volume pubsub filters, configure + /// [`AlloySubscriber::with_log_verification_provider`] with a separate HTTP + /// provider so verification responses cannot be starved by notifications. + pub verify_log_block_context: bool, + /// Maximum records to emit per batch. + pub max_batch_size: usize, + /// Maximum distinct contract addresses placed in one provider-side log + /// subscription. Compatible logical owner filters are fanned into address + /// supersets up to this limit; exact owner routing still happens locally. + pub max_log_addresses_per_subscription: usize, + /// Maximum records retained across the delivery queue and hidden + /// transaction-aware reconcile buffer. Exceeding it fails the subscriber + /// closed until a full interest reset, because dropping an event would + /// create an unknowable continuity gap. + pub max_pending_records: usize, + /// Maximum lazy owner-backfill requests retained at once. + pub max_pending_backfills: usize, + /// Maximum approximate encoded bytes accepted from one historical log + /// response (fixed log identity fields, topics, and data). + pub max_backfill_log_bytes: usize, + /// Maximum provider log requests concurrently in flight during bulk owner + /// reconciliation. + pub max_reconcile_requests_in_flight: usize, + /// Reconnect policy for WebSocket/pubsub streams. + pub reconnect: SubscriberReconnectConfig, +} + +impl Default for SubscriberConfig { + fn default() -> Self { + Self { + preconfirmations: PreconfirmationMode::Disabled, + flashblock_poll_interval: Duration::from_millis(100), + hydrate_pending_transactions: false, + verify_log_block_context: false, + max_batch_size: 1024, + max_log_addresses_per_subscription: 1024, + max_pending_records: 16_384, + max_pending_backfills: 4_096, + max_backfill_log_bytes: 64 * 1024 * 1024, + max_reconcile_requests_in_flight: 8, + reconnect: SubscriberReconnectConfig::default(), } - self.trim_journal(); } +} - fn record_journal_applied(&mut self, block: &BlockRef, applied: AppliedReport) { - self.journal_entry_mut(block).applied.push(applied); - self.trim_journal(); - } +/// WebSocket/pubsub reconnect policy. +/// +/// Reconnects are applied after an established subscription stream terminates. +/// Initial subscription failures are still returned immediately so deployment +/// mistakes, unsupported transports, and bad endpoints fail fast. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubscriberReconnectConfig { + /// Whether pubsub streams should be recreated after termination. + pub enabled: bool, + /// Delay before the first reconnect attempt. + pub initial_delay: Duration, + /// Delay before the second reconnect attempt. Later retries double this + /// delay up to [`Self::max_delay`]. + pub retry_delay: Duration, + /// Maximum delay between reconnect attempts. + pub max_delay: Duration, + /// Maximum reconnect attempts per terminated stream. `None` retries forever. + pub max_attempts: Option, + /// Number of recently emitted canonical input refs remembered to suppress + /// duplicates across reconnect backfill and subscription replay. + pub dedupe_window: usize, +} - fn record_journal_resync(&mut self, report: &ResyncReport) { - if report.diff.is_empty() { - return; +impl Default for SubscriberReconnectConfig { + fn default() -> Self { + Self { + enabled: true, + initial_delay: Duration::ZERO, + retry_delay: Duration::from_millis(250), + max_delay: Duration::from_secs(30), + max_attempts: Some(3), + dedupe_window: 4096, } - let Some(block) = single_hash_pinned_resync_block(report) else { - return; - }; - self.journal_entry_mut(&block).resynced.push(report.clone()); - self.trim_journal(); } +} - fn journal_entry_mut(&mut self, block: &BlockRef) -> &mut BlockJournal { - if let Some(index) = self - .journal - .iter() - .position(|entry| entry.block.hash == block.hash && entry.block.number == block.number) - { - return &mut self.journal[index]; - } - - self.journal.push_back(BlockJournal { - block: block.clone(), - inputs: Vec::new(), - applied: Vec::new(), - resynced: Vec::new(), - }); - let index = self.journal.len() - 1; - &mut self.journal[index] - } +/// Historical log backfill requested when adding subscriber interests. +/// +/// Backfill applies only to [`ReactiveInterest::Logs`] entries. Block and +/// pending-transaction interests are live-only. `AlloySubscriber` emits records +/// fetched through this policy as [`InputSource::Backfill`]. Continuity-safe +/// owner registration adopts/subscribes the desired live filter first, then +/// reconciles history behind that live fence; startup/global replacement commits +/// topology and historical work as one desired-state transaction. A drained +/// backfill seeds the filter's delivery anchor at its resolved upper bound (even +/// when the window held no logs), so the newly added filter gets the same +/// reconnect/catch-up protection an established one has. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SubscriberBackfill { + from_block: u64, + to_block: Option, + retained_anchor: Option, +} - fn trim_journal(&mut self) { - if self.config.journal_depth == 0 { - self.journal.clear(); - return; - } - while self.journal.len() > self.config.journal_depth { - self.journal.pop_front(); +impl SubscriberBackfill { + /// Backfill an inclusive block range. + pub fn range(from_block: u64, to_block: u64) -> Self { + Self { + from_block, + to_block: Some(to_block), + retained_anchor: None, } } - fn drain_journal_after(&mut self, index: usize) -> Vec> { - self.journal.drain((index + 1)..).collect() - } - - fn drain_journal_from(&mut self, index: usize) -> Vec> { - self.journal.drain(index..).collect() + /// Backfill from `from_block` through the provider's latest block. + pub fn from_block(from_block: u64) -> Self { + Self { + from_block, + to_block: None, + retained_anchor: None, + } } - fn drain_journal_from_number(&mut self, number: u64) -> Vec> { - let Some(index) = self - .journal - .iter() - .position(|entry| entry.block.number >= number) - else { - return Vec::new(); - }; - self.drain_journal_from(index) + /// Backfill inclusively from an exact retained canonical block. + /// + /// The Alloy subscriber verifies this number/hash against its provider + /// before accepting any lazy catch-up response. Engine-managed mid-stream + /// registration uses this form so owner replay cannot silently cross a + /// reorged discovery boundary. + pub fn from_canonical_block(block: BlockRef) -> Self { + Self { + from_block: block.number, + to_block: None, + retained_anchor: Some(block), + } } - fn recover_dropped_journals( - &mut self, - cache: &mut EvmCache, - dropped: Vec>, - reason: ReorgReason, - ) -> Option> { - if dropped.is_empty() { - return None; + /// Backfill inclusively from an exact canonical block through an inclusive + /// upper bound. + /// + /// # Errors + /// + /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the + /// retained anchor. + pub fn from_canonical_block_through( + block: BlockRef, + to_block: u64, + ) -> Result { + if to_block < block.number { + return Err(SubscriberError::InvalidConfig( + "inclusive backfill upper bound precedes its retained anchor", + )); } - - let dropped_blocks: Vec<_> = dropped.iter().map(|entry| entry.block.clone()).collect(); - let dropped_inputs: Vec<_> = dropped - .iter() - .flat_map(|entry| entry.inputs.iter().copied()) - .collect(); - let canceled_resyncs = self.cancel_resyncs_for_dropped_blocks(&dropped_blocks); - let purge_scopes = purge_scopes_for_dropped_journals(&dropped); - let rollback_updates = rollback_updates_for_dropped_journals(&dropped, &purge_scopes); - let purge_updates: Vec<_> = purge_scopes - .iter() - .map(|(address, scope)| StateUpdate::purge(*address, scope.clone())) - .collect(); - - let rollback_diff = if rollback_updates.is_empty() { - StateDiff::default() - } else { - cache.apply_updates(&rollback_updates) - }; - let purge_diff = if purge_updates.is_empty() { - StateDiff::default() - } else { - cache.apply_updates(&purge_updates) - }; - - Some(ReorgReport { - dropped: dropped_blocks.first().cloned(), - dropped_blocks, - dropped_inputs, - rollback_updates, - rollback_diff, - purge_updates, - purge_diff, - canceled_resyncs, - reason, - _network: PhantomData, + Ok(Self { + from_block: block.number, + to_block: Some(to_block), + retained_anchor: Some(block), }) } - - fn cancel_resyncs_for_dropped_blocks( - &mut self, - dropped_blocks: &[BlockRef], - ) -> Vec { - let mut canceled = Vec::new(); - self.pending_resyncs.retain(|request| { - let should_cancel = resync_request_targets_dropped_block(request, dropped_blocks); - if should_cancel { - canceled.push(request.clone()); - } - !should_cancel - }); - canceled + + /// Backfill strictly after an exact canonical state baseline. + /// + /// This is distinct from [`from_canonical_block`](Self::from_canonical_block): + /// a restored cache already embodies every effect through `block`, so + /// replaying that block would apply it twice. The retained block is still + /// carried so the subscriber can prove that its provider is on the same + /// canonical branch before accepting any post-baseline history. + /// + /// Returns an error at `u64::MAX`; silently saturating would turn an empty + /// exclusive range into an inclusive replay of the baseline block. + /// + /// # Errors + /// + /// Returns [`SubscriberError::InvalidConfig`] when the baseline number is + /// `u64::MAX` and therefore has no following block. + pub fn after_canonical_block(block: BlockRef) -> Result { + Self::after_canonical_block_inner(block, None) } - fn remove_pending_resyncs<'a>(&mut self, ids: impl IntoIterator) { - let ids: HashSet<_> = ids.into_iter().cloned().collect(); - self.pending_resyncs - .retain(|request| !ids.contains(&request.id)); + /// Backfill strictly after an exact canonical baseline through an + /// inclusive upper bound. + /// + /// `to_block == block.number` represents a deliberately empty certified + /// interval. Bounds before the retained baseline are rejected. + /// + /// # Errors + /// + /// Returns [`SubscriberError::InvalidConfig`] when `to_block` precedes the + /// baseline, or when a non-empty exclusive range would have to begin after + /// block `u64::MAX`. + pub fn after_canonical_block_through( + block: BlockRef, + to_block: u64, + ) -> Result { + if to_block < block.number { + return Err(SubscriberError::InvalidConfig( + "exclusive backfill upper bound precedes its retained baseline", + )); + } + Self::after_canonical_block_inner(block, Some(to_block)) } -} -/// Fold every address a [`StateDiff`] references — genuine changes -/// (`slots`/`accounts`/`purged`) and cold-skipped attempts (`skipped*`) alike — -/// into `into`. Used by the per-block root gate to accumulate the batch's -/// decoder-touched address set: an account a decoder wrote (or tried to write) is -/// "covered," so a subsequent root move for it is not a coverage gap. -fn collect_diff_addresses(diff: &StateDiff, into: &mut HashSet
) { - into.extend(diff.slots.iter().map(|change| change.address)); - into.extend(diff.accounts.iter().map(|change| change.address)); - into.extend(diff.purged.iter().map(|purge| purge.address)); - into.extend(diff.skipped.iter().map(|skipped| skipped.address)); - into.extend(diff.skipped_balances.iter().map(|skipped| skipped.address)); - into.extend(diff.skipped_masks.iter().map(|skipped| skipped.address)); - into.extend(diff.skipped_accounts.iter().map(|skipped| skipped.address)); -} + fn after_canonical_block_inner( + block: BlockRef, + to_block: Option, + ) -> Result { + let from_block = block + .number + .checked_add(1) + .ok_or(SubscriberError::InvalidConfig( + "cannot construct an exclusive backfill after block u64::MAX", + ))?; + Ok(Self { + from_block, + to_block, + retained_anchor: Some(block), + }) + } -/// Build the [`ResyncReason::RootMoved`] account resync the root gate schedules -/// for an uncovered move. Re-reads `address`'s `fields` at `block` through the -/// existing account-resync path (Wave 2). The id is derived from the address and -/// block so a repeated move on the same account/block coalesces deterministically. -fn root_moved_account_resync( - address: Address, - block: u64, - fields: AccountFieldMask, -) -> ResyncRequest { - ResyncRequest { - id: ResyncId::new(format!("root-moved:{address:#x}:{block}")), - reason: ResyncReason::RootMoved, - block: ResyncBlock::Number(block), - targets: vec![ResyncTarget::Account { address, fields }], - priority: ResyncPriority::Normal, + /// First block included in the backfill. + pub fn start_block(&self) -> u64 { + self.from_block } -} -fn canonical_record_block(record: &ReactiveInputRecord) -> Option<&BlockRef> { - if matches!(&record.input, ReactiveInput::Log(log) if log.removed) { - return None; + /// Last block included in the backfill, or `None` for provider latest. + pub fn end_block(&self) -> Option { + self.to_block } - if is_canonical_status(&record.context.chain_status) { - return context_block_ref(&record.context); + + /// Exact retained start-block identity, when supplied. + pub fn retained_anchor(&self) -> Option<&BlockRef> { + self.retained_anchor.as_ref() } - None } -/// Best-effort per-block env refresh (Phase-8 step 2). +/// Opaque generation for one transaction-aware subscriber interest owner. /// -/// For a canonical record carrying a full header — a -/// [`ReactiveInput::BlockHeader`] or [`ReactiveInput::FullBlock`] — refresh the -/// cache's block env from that header via [`EvmCache::advance_block`]. Returns -/// `Some(result)` when a header was present (so the caller can surface a strict -/// validation error), and `None` for pending/reorged records or non-header -/// inputs, which must never drive a canonical env refresh. -fn advance_block_for_canonical_record( - cache: &mut EvmCache, - record: &ReactiveInputRecord, -) -> Option> { - if !is_canonical_status(&record.context.chain_status) { - return None; - } - match &record.input { - ReactiveInput::BlockHeader(header) => Some(cache.advance_block(header)), - ReactiveInput::FullBlock(block) => Some(cache.advance_block(block.header())), - _ => None, - } +/// Epochs are allocated monotonically by [`AlloySubscriber`] and are never +/// reused, including after an aborted stage or a full interest replacement. +/// Lifecycle operations require the complete token so a delayed command for an +/// older registration cannot affect a replacement using the same [`HandlerId`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SubscriberOwnerEpoch { + owner: HandlerId, + sequence: u64, } -fn context_block_ref(ctx: &ReactiveContext) -> Option<&BlockRef> { - match &ctx.chain_status { - ChainStatus::Included { block, .. } - | ChainStatus::Safe { block } - | ChainStatus::Finalized { block } => Some(block), - ChainStatus::Reorged { dropped_from } => Some(dropped_from), - ChainStatus::Pending => ctx.block.as_ref(), - } +/// Delivery audience retained with a subscriber input record. +/// +/// Canonical inputs are forwarded once to the runtime actor and may also name +/// staged epochs that need a buffered copy. Owner-only inputs are catch-up or +/// overlap records that must never be routed through existing canonical +/// handlers. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum SubscriberInputScope { + /// One canonical input plus any staged owners that matched at enqueue time. + Canonical { + /// Staged owner epochs that require a buffered copy. + owners: Vec, + }, + /// Canonical input whose owner catch-up already delivered selected handler + /// owners. The residual canonical copy must exclude those handlers while + /// remaining authoritative for global chain progress. + CanonicalResidual { + /// Staged epoch owners that still require a buffered copy. + owners: Vec, + /// Active compatibility owners already served by owner catch-up. + excluded: Vec, + }, + /// Input delivered only to the listed staged owners. + OwnerOnly { + /// Exact staged owner epochs receiving the input. + owners: Vec, + }, + /// Compatibility owner-only delivery keyed by stable handler id. + OwnerOnlyHandlers { + /// Exact active handlers receiving the catch-up input. + owners: Vec, + }, + /// Flashblock input routed through ordinary matching handlers but applied + /// only to the speculative overlay. + Preconfirmed, } -fn reorg_signal_block( - record: &ReactiveInputRecord, -) -> Option<(BlockRef, ReorgReason)> { - if matches!(&record.input, ReactiveInput::Log(log) if log.removed) { - return block_ref_from_record(record).map(|block| (block, ReorgReason::RemovedLog)); +impl SubscriberInputScope { + /// Exact staged owner epochs attached to this input. + pub fn owners(&self) -> &[SubscriberOwnerEpoch] { + match self { + Self::Canonical { owners } + | Self::CanonicalResidual { owners, .. } + | Self::OwnerOnly { owners } => owners, + Self::OwnerOnlyHandlers { .. } | Self::Preconfirmed => &[], + } } - if let ChainStatus::Reorged { dropped_from } = &record.context.chain_status { - return Some((dropped_from.clone(), ReorgReason::ReorgedInput)); + /// Whether this input must be forwarded once through canonical routing. + pub const fn is_canonical(&self) -> bool { + matches!( + self, + Self::Canonical { .. } | Self::CanonicalResidual { .. } + ) } - None + /// Whether this input belongs only to the disposable preconfirmed overlay. + pub const fn is_preconfirmed(&self) -> bool { + matches!(self, Self::Preconfirmed) + } } -fn block_ref_from_record(record: &ReactiveInputRecord) -> Option { - context_block_ref(&record.context) - .cloned() - .or_else(|| match &record.input { - ReactiveInput::Log(log) => Some(BlockRef { - number: log.block_number?, - hash: log.block_hash?, - parent_hash: None, - timestamp: log.block_timestamp, - }), - ReactiveInput::BlockHeader(header) => Some(BlockRef { - number: header.number(), - hash: header.hash(), - parent_hash: Some(header.parent_hash()), - timestamp: Some(header.timestamp()), - }), - ReactiveInput::FullBlock(block) => { - let header = block.header(); - Some(BlockRef { - number: header.number(), - hash: header.hash(), - parent_hash: Some(header.parent_hash()), - timestamp: Some(header.timestamp()), - }) - } - ReactiveInput::PendingTxHash(_) | ReactiveInput::PendingTx(_) => None, - }) +/// Reactive input together with its canonical/owner-scoped delivery audience. +#[derive(Clone, Debug)] +pub struct SubscriberInputRecord { + record: ReactiveInputRecord, + scope: SubscriberInputScope, } -fn remove_canceled_resyncs_from_batch( - resyncs: &mut Vec, - canceled: &[ResyncRequest], -) { - if canceled.is_empty() { - return; +impl SubscriberInputRecord { + /// Borrow the reactive input record. + pub const fn record(&self) -> &ReactiveInputRecord { + &self.record } - let canceled_ids: HashSet<_> = canceled.iter().map(|request| request.id.clone()).collect(); - resyncs.retain(|request| !canceled_ids.contains(&request.id)); -} -fn resync_target_address(target: &ResyncTarget) -> Address { - match target { - ResyncTarget::StorageSlot { address, .. } - | ResyncTarget::StorageSlots { address, .. } - | ResyncTarget::Account { address, .. } => *address, + /// Delivery audience captured when the record was enqueued. + pub const fn scope(&self) -> &SubscriberInputScope { + &self.scope } -} -fn resync_request_targets_dropped_block( - request: &ResyncRequest, - dropped_blocks: &[BlockRef], -) -> bool { - let ResyncBlock::Hash { number, hash, .. } = &request.block else { - return false; - }; - dropped_blocks - .iter() - .any(|block| block.hash == *hash && block.number == *number) + /// Consume the scoped value into its reactive input record. + pub fn into_record(self) -> ReactiveInputRecord { + self.record + } } -fn single_hash_pinned_resync_block(report: &ResyncReport) -> Option { - let first = report.requested.first()?.block.clone(); - if !report - .requested - .iter() - .all(|request| request.block == first) - { - return None; - } +impl std::ops::Deref for SubscriberInputRecord { + type Target = ReactiveInputRecord; - let ResyncBlock::Hash { number, hash, .. } = first else { - return None; - }; + fn deref(&self) -> &Self::Target { + &self.record + } +} - Some(BlockRef { - number, - hash, - parent_hash: None, - timestamp: None, - }) +/// Batch of subscriber inputs with enqueue-time owner provenance. +#[derive(Clone, Debug)] +pub struct SubscriberInputBatch { + records: Vec>, + chain_id: Option, + chain_controls: Vec, } -fn purge_scopes_for_dropped_journals( - dropped: &[BlockJournal], -) -> Vec<(Address, PurgeScope)> { - let mut scopes: Vec<(Address, PurgeScope)> = Vec::new(); - for entry in dropped.iter().rev() { - for resynced in entry.resynced.iter().rev() { - merge_purge_scopes_for_diff(&mut scopes, &resynced.diff); - } - for applied in entry.applied.iter().rev() { - merge_purge_scopes_for_diff(&mut scopes, &applied.diff); - } - } - scopes +/// Result of polling a scoped subscriber batch against one driver control +/// future. +#[derive(Debug)] +#[non_exhaustive] +pub enum SubscriberDriverPoll { + /// The control future completed first; subscriber delivery remains intact. + Control(C), + /// Subscriber polling completed first. + Batch(Option>), } -fn rollback_updates_for_dropped_journals( - dropped: &[BlockJournal], - purge_scopes: &[(Address, PurgeScope)], -) -> Vec { - let purge_addresses: HashSet<_> = purge_scopes - .iter() - .map(|(address, _scope)| *address) - .collect(); - let mut updates = Vec::new(); - for entry in dropped.iter().rev() { - for resynced in entry.resynced.iter().rev() { - push_rollback_updates_for_diff(&mut updates, &resynced.diff, &purge_addresses); - } - for applied in entry.applied.iter().rev() { - push_rollback_updates_for_diff(&mut updates, &applied.diff, &purge_addresses); - } +impl SubscriberInputBatch { + /// Borrow every scoped record in delivery order. + pub fn records(&self) -> &[SubscriberInputRecord] { + &self.records } - updates -} -fn merge_purge_scopes_for_diff(scopes: &mut Vec<(Address, PurgeScope)>, diff: &StateDiff) { - for change in &diff.accounts { - merge_purge_scope(scopes, change.address, PurgeScope::Account); + /// Consume the batch into its scoped records. + pub fn into_records(self) -> Vec> { + self.records } - for record in &diff.purged { - merge_purge_scope(scopes, record.address, record.scope.clone()); + + /// Ordered chain controls committed after the preceding records. + pub fn chain_controls(&self) -> &[ChainControl] { + &self.chain_controls } -} -fn push_rollback_updates_for_diff( - updates: &mut Vec, - diff: &StateDiff, - purge_addresses: &HashSet
, -) { - for change in diff.slots.iter().rev() { - if purge_addresses.contains(&change.address) { - continue; + /// Consume the scoped subscriber delivery into a runtime-ready batch. + /// + /// Delivery audiences and the preconfirmed/canonical boundary are retained, + /// allowing downstream owner actors to forward a batch without rebuilding + /// subscriber-internal scope metadata. + pub fn into_reactive_batch(self) -> ReactiveInputBatch { + let chain_id = self.chain_id; + let chain_controls = self.chain_controls; + let mut batch = ReactiveInputBatch::from_scoped_records_with_delivery_scope( + self.records.into_iter().map(|scoped| { + let source = scoped.record.context.source; + let (audience, delivery_scope) = match scoped.scope { + SubscriberInputScope::Canonical { .. } => ( + DeliveryAudience::All, + if source == InputSource::Backfill { + DeliveryScope::CanonicalProgress + } else { + DeliveryScope::Canonical + }, + ), + SubscriberInputScope::CanonicalResidual { excluded, .. } => ( + DeliveryAudience::AllExcept(excluded), + if source == InputSource::Backfill { + DeliveryScope::CanonicalProgress + } else { + DeliveryScope::Canonical + }, + ), + SubscriberInputScope::OwnerOnly { owners } => { + let mut handler_ids = Vec::with_capacity(owners.len()); + for epoch in owners { + if !handler_ids.contains(epoch.owner()) { + handler_ids.push(epoch.owner().clone()); + } + } + ( + DeliveryAudience::Owners(handler_ids), + DeliveryScope::OwnerCatchup, + ) + } + SubscriberInputScope::OwnerOnlyHandlers { owners } => ( + DeliveryAudience::Owners(owners), + DeliveryScope::OwnerCatchup, + ), + SubscriberInputScope::Preconfirmed => { + (DeliveryAudience::All, DeliveryScope::Preconfirmed) + } + }; + (scoped.record, audience, delivery_scope) + }), + ) + .with_chain_controls(chain_controls); + if let Some(chain_id) = chain_id { + batch = batch.with_chain_id(chain_id); } - updates.push(StateUpdate::slot(change.address, change.slot, change.old)); + batch } } -fn merge_purge_scope(scopes: &mut Vec<(Address, PurgeScope)>, address: Address, scope: PurgeScope) { - if let Some((_existing_address, existing_scope)) = scopes - .iter_mut() - .find(|(existing_address, _scope)| *existing_address == address) - { - *existing_scope = merged_purge_scope(existing_scope.clone(), scope); - } else { - scopes.push((address, scope)); +impl SubscriberOwnerEpoch { + /// Logical subscriber owner represented by this epoch. + pub const fn owner(&self) -> &HandlerId { + &self.owner } -} -fn merged_purge_scope(left: PurgeScope, right: PurgeScope) -> PurgeScope { - match (left, right) { - (PurgeScope::Account, _) | (_, PurgeScope::Account) => PurgeScope::Account, - (PurgeScope::AllStorage, _) | (_, PurgeScope::AllStorage) => PurgeScope::AllStorage, - (PurgeScope::Slots(mut left), PurgeScope::Slots(right)) => { - for slot in right { - if !left.contains(&slot) { - left.push(slot); - } - } - PurgeScope::Slots(left) - } + /// Monotonic subscriber-local epoch sequence. + pub const fn sequence(&self) -> u64 { + self.sequence } } -#[derive(Clone, Debug)] -struct StorageFetchSlot { - address: Address, - slot: U256, - origins: Vec, +/// Catch-up policy applied when staging a transaction-aware interest owner. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum SubscriberOwnerStart { + /// Start with live delivery only. + Live, + /// Start strictly after an already-applied post-block baseline. + /// + /// A baseline at block `N` schedules backfill from `N + 1`; block `N` + /// itself is never replayed. Transaction-aware callers explicitly call + /// [`AlloySubscriber::reconcile_interest_owner`] before activation; staged + /// owners never use the legacy lazy-backfill queue. + PostBlock(BlockRef), } -#[derive(Clone, Debug)] -struct StorageFetchOrigin { - request_id: ResyncId, - target: ResyncTarget, +/// Transaction state of one epoch-scoped subscriber owner. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum SubscriberOwnerState { + /// Desired interests and owner-scoped buffering are installed but canonical + /// routing has not yet committed. + Staged, + /// Canonical runtime routing has committed for this owner. + Active, + /// Removal is prepared behind a delivery fence but remains reversible. + Removing, } -#[derive(Clone, Debug)] -struct StorageFetchGroup { - block: ResyncBlock, - slots: Vec, - seen: HashSet<(Address, U256)>, +/// Hash-certified catch-up position reached by one subscriber owner epoch. +/// +/// Progress means every owner-only record through this point has been fetched +/// and queued inside the subscriber. It does not mean the downstream actor has +/// drained or committed those records; that requires a separate delivery fence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubscriberOwnerProgress { + owner: SubscriberOwnerEpoch, + through: BlockRef, } -/// One account-target resync collected during request scanning, resolved through -/// the account proof fetcher after storage groups are processed. -#[derive(Clone, Debug)] -struct AccountResyncTarget { - request_id: ResyncId, - block: ResyncBlock, - address: Address, - fields: AccountFieldMask, +impl SubscriberOwnerProgress { + /// Exact owner epoch whose catch-up was reconciled. + pub const fn owner(&self) -> &SubscriberOwnerEpoch { + &self.owner + } + + /// Verified canonical block through which owner input was fetched. + pub const fn through(&self) -> &BlockRef { + &self.through + } } -fn resolve_trace_resyncs( - cache: &EvmCache, - storage_groups: &mut Vec, - account_targets: &mut Vec, - state_updates: &mut Vec, -) { - let Some(fetcher) = cache.block_state_diff_fetcher().cloned() else { - return; - }; +/// Error staging a transaction-aware subscriber owner. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SubscriberOwnerError { + /// Subscriber configuration or interest validation failed. + #[error(transparent)] + Subscriber(#[from] SubscriberError), + /// The logical owner already has desired interests installed. + #[error("subscriber interest owner `{0}` is already registered")] + AlreadyRegistered(HandlerId), + /// A post-block baseline cannot be advanced to its first unapplied block. + #[error("post-block subscriber baseline {0} has no following block")] + PostBlockOverflow(u64), + /// The monotonic subscriber owner epoch sequence was exhausted. + #[error("subscriber owner epoch sequence exhausted")] + EpochExhausted, + /// The exact owner epoch is unknown or no longer staged. + #[error("subscriber owner epoch is not staged")] + NotStaged, + /// Live-only staging has no historical baseline to reconcile. + #[error("subscriber owner was staged live-only and has no catch-up baseline")] + MissingBaseline, + /// Post-block reconciliation currently covers log interests only. + #[error("post-block subscriber owners support log interests only")] + UnsupportedPostBlockInterest, + /// The target block was absent from the provider. + #[error("subscriber reconcile target block {0} was not found")] + BlockUnavailable(u64), + /// The provider's canonical identity did not match the requested target. + #[error( + "subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}" + )] + BlockMismatch { + /// Requested block number. + expected_number: u64, + /// Requested block hash. + expected_hash: B256, + /// Provider block number. + actual_number: u64, + /// Provider block hash. + actual_hash: B256, + }, + /// A reconcile target was older than the retained baseline/progress. + #[error("subscriber reconcile target block {target} precedes current owner position {current}")] + ProgressRegression { + /// Retained baseline or progress block. + current: u64, + /// Rejected target block. + target: u64, + }, + /// A reconcile attempted to replace a retained block identity at the same + /// height or cross an immediate parent that does not extend it. + #[error( + "subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}" + )] + ProgressConflict { + /// Retained baseline or progress block number. + number: u64, + /// Retained baseline or progress block hash. + current_hash: B256, + /// Conflicting target hash or immediate parent hash. + target_hash: B256, + }, + /// A provider returned a malformed or out-of-range catch-up log. + #[error("subscriber reconcile returned an invalid catch-up log: {0}")] + InvalidBackfillLog(&'static str), +} - let mut blocks = Vec::new(); - let mut seen = HashSet::new(); - for block in storage_groups - .iter() - .map(|group| group.block.clone()) - .chain(account_targets.iter().map(|target| target.block.clone())) - { - if seen.insert(block.clone()) { - blocks.push(block); - } +/// Extension trait for subscribers that can add and remove handler-owned +/// interests incrementally. +/// +/// [`EventSubscriber::register_interests`] remains the full-replacement setup +/// API. Implement this trait when a subscriber can preserve unrelated live +/// sources and delivery state while one handler's interests are added or +/// removed. Implementations should make owner *replacement* continuity-safe: +/// updating an owner's interests must not silently discard delivery progress +/// the previous interests had already established (the in-crate +/// [`AlloySubscriber`] carries the owner's prior delivery anchor over to +/// changed filter shapes and automatically backfills the gap). Every mutating +/// operation is also a commit boundary: returning `Ok` means the new desired +/// state is authoritative, while errors or cancellation must preserve the +/// previous state or reconcile before exposing the uncommitted change. +pub trait InterestOwnerSubscriber: EventSubscriber { + /// Atomically add or replace several owners in one desired-state revision. + /// + /// Unrelated owners remain installed. Returning `Ok(())` is one commit + /// boundary for the complete set; an error or cancellation must leave the + /// previously committed owner topology authoritative. Durable remote + /// subscribers should override this method so bootstrap creates one service + /// revision and one activation barrier rather than one barrier per owner. + /// + /// # Errors + /// + /// The returned operation reports [`SubscriberError::Unsupported`] by + /// default, or an implementation-specific validation or commit failure. + fn upsert_interest_owners( + &mut self, + _owners: Vec<(HandlerId, Vec>)>, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { + Err(SubscriberError::Unsupported( + "subscriber does not implement atomic bulk owner upsert", + )) + }) + } + + /// Atomically replace the complete engine-managed owner topology without + /// requesting history. + /// + /// This is the fresh-runtime bootstrap operation. Base/unowned interests, + /// stale owners, queued delivery, and dedupe/source state from the prior + /// topology must not survive a successful replacement. Errors and dropped + /// futures leave the prior committed topology authoritative. + /// + /// # Errors + /// + /// The returned operation reports [`SubscriberError::Unsupported`] by + /// default, or an implementation-specific validation or commit failure. + fn replace_interest_owners( + &mut self, + _owners: Vec<(HandlerId, Vec>)>, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { + Err(SubscriberError::Unsupported( + "subscriber does not implement atomic exact owner replacement", + )) + }) } - let mut traces = HashMap::new(); - for block in blocks { - match (fetcher)(resync_block_to_block_id(&block)) { - Ok(diff) => { - traces.insert(block, diff); - } - Err(error) => { - tracing::debug!( - block = ?block, - error = %error, - "block trace resync source failed; falling back to point resync" - ); - } - } + /// Atomically replace the complete owner set and schedule one global + /// historical log backfill in the same desired-state revision. + /// + /// This is the continuity-safe bootstrap operation for a runtime that has + /// already processed canonical state while the subscriber's owner state is + /// new or may have been lost. Implementations must commit the complete + /// owner topology and all required historical work together: returning an + /// error or dropping the future must leave the previously committed state + /// authoritative. The default is deliberately unsupported rather than a + /// sequence of partially committed single-owner updates. + /// Historical records must be delivered through canonical global routing + /// (`DeliveryAudience::All` / `DeliveryScope::CanonicalProgress`), not as + /// owner catch-up, so their effects participate in the normal rollback + /// journal before the source certifies the cutover. Base/unowned interests + /// are replaced by this complete engine-managed topology. Any owner absent + /// from `owners` must be removed together with its queued owner-only work, which closes + /// the crash window where a subscriber committed registration but the + /// runtime process died before installing the corresponding handler. + /// + /// # Errors + /// + /// The returned operation reports [`SubscriberError::Unsupported`] by + /// default, or a backfill, validation, transport, or atomic-commit failure. + fn replace_interest_owners_with_global_backfill( + &mut self, + _owners: Vec<(HandlerId, Vec>)>, + _backfill: SubscriberBackfill, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { + Err(SubscriberError::Unsupported( + "subscriber does not implement atomic owner replacement with global backfill", + )) + }) } - for group in storage_groups.iter_mut() { - let Some(trace) = traces.get(&group.block) else { - continue; - }; - group.slots.retain(|slot| { - if let Some(value) = trace_storage_value(trace, slot.address, slot.slot) { - state_updates.push(StateUpdate::slot(slot.address, slot.slot, value)); - return false; - } - cache - .cached_storage_value(slot.address, slot.slot) - .is_none() - }); - group.seen = group - .slots - .iter() - .map(|slot| (slot.address, slot.slot)) - .collect(); + /// Add or replace the interests owned by `owner`, awaiting the subscriber's + /// commit boundary. + /// + /// Implementations must leave the previously committed owner state + /// authoritative when the operation returns an error or is cancelled before + /// completion. + /// + /// # Errors + /// + /// The returned operation reports [`SubscriberError`] when the owner update + /// cannot be validated or committed. + fn add_interest_owner( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()>; + + /// Add or replace owner interests and schedule log backfill for that owner, + /// awaiting the subscriber's commit boundary. + /// + /// # Errors + /// + /// The returned operation reports [`SubscriberError`] when the owner update + /// or requested backfill cannot be validated or committed. + fn add_interest_owner_with_backfill( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + backfill: SubscriberBackfill, + ) -> SubscriberOperation<'_, ()>; + + /// Add a handler discovered at retained canonical block `C` without + /// opening a gap while registration commits. + /// + /// The subscriber must subscribe/adopt the new desired state first, then + /// expose the new owner's matching records from `C` as owner catch-up and + /// expose `C + 1` through the activation head as one globally ordered + /// canonical catch-up over the complete active interest union. This split + /// is deliberate: the runtime already has a rollback entry for `C`, while + /// later blocks must run every handler and create normal canonical journal + /// entries. Errors/cancellation preserve the prior committed topology. + /// Implementations that cannot uphold this coordinated transaction must + /// return `Unsupported`; emitting owner-only records past `C` is invalid. + /// + /// # Errors + /// + /// The returned operation reports [`SubscriberError::Unsupported`] by + /// default, or a canonical-anchor, transport, or atomic-commit failure. + fn add_interest_owner_with_canonical_catchup( + &mut self, + _owner: HandlerId, + _interests: &[ReactiveInterest], + _retained: BlockRef, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { + Err(SubscriberError::Unsupported( + "subscriber does not implement coordinated canonical owner catch-up", + )) + }) } - storage_groups.retain(|group| !group.slots.is_empty()); - let mut unresolved_accounts = Vec::new(); - for mut account in account_targets.drain(..) { - let Some(trace) = traces.get(&account.block) else { - unresolved_accounts.push(account); - continue; - }; - let Some(trace_account) = trace - .accounts - .iter() - .find(|diff| diff.address == account.address) - else { - unresolved_accounts.push(account); - continue; - }; + /// Remove one owner's interests, preserving unrelated interests, and await + /// acknowledgement that the removal committed. + /// + /// On error the owner must remain authoritative, so the runtime handler is + /// not removed while subscriber delivery may still target it. + /// + /// # Errors + /// + /// The returned operation reports [`SubscriberError`] when the removal + /// cannot be committed while preserving unrelated owners. + fn remove_interest_owner( + &mut self, + owner: &HandlerId, + ) -> SubscriberOperation<'_, Option>>>; - let mut patch = AccountPatch::default(); - let mut unresolved = AccountFieldMask::default(); - if account.fields.balance { - if let Some(balance) = trace_account.balance { - patch = patch.balance(balance); - } else { - unresolved.balance = true; - } - } - if account.fields.nonce { - if let Some(nonce) = trace_account.nonce { - patch = patch.nonce(nonce); - } else { - unresolved.nonce = true; - } - } - if account.fields.code { - if let Some(code) = &trace_account.code { - patch = patch.code(code.clone()); - } else { - unresolved.code = true; - } - } + /// Borrow the interests currently owned by `owner`. + fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest]>; +} - if patch.balance.is_some() || patch.nonce.is_some() || patch.code.is_some() { - state_updates.push(StateUpdate::account_upsert(account.address, patch)); - } - if !account_field_mask_empty(unresolved) { - account.fields = unresolved; - unresolved_accounts.push(account); - } - } - *account_targets = unresolved_accounts; +/// Binds a [`ReactiveRuntime`] to an [`EventSubscriber`] for the common +/// subscribe-ingest lifecycle. +/// +/// The engine treats the runtime registry as the single source of truth for +/// handler lifecycle: [`register_handler`](Self::register_handler) and +/// [`unregister_handler`](Self::unregister_handler) update runtime routing and +/// subscriber interests as one operation, keyed by the handler's stable +/// [`HandlerId`]. Registration is continuity-safe by default — once the runtime +/// has journaled canonical block *N*, a newly registered handler is live-adopted, +/// replayed owner-only at *N*, and then caught up globally with every handler +/// from *N + 1* through activation. A factory-discovered pool therefore misses +/// none of its own logs without making later history owner-local and +/// unrollbackable. The subscriber must absorb overlap that crosses batch +/// boundaries; the runtime validates and merges duplicate representations only +/// within one [`ReactiveInputBatch`]. +/// +/// Registration methods by intent: +/// +/// | Method | Backfill | +/// |---|---| +/// | [`register_handler`](Self::register_handler) | coordinated owner replay at the last retained block plus global catch-up above it (live-only on a fresh runtime) | +/// | [`register_handler_with_backfill`](Self::register_handler_with_backfill) | exactly one hash-certified block still retained by the rollback journal | +/// | [`register_handler_live_only`](Self::register_handler_live_only) | none — future logs only | +/// +/// Unregistering a handler stops future subscription routing and runtime +/// decode for that handler; it deliberately does not evict [`EvmCache`] state +/// or undo runtime side effects. See +/// [`unregister_handler`](Self::unregister_handler) for the complete teardown +/// recipe. +/// +/// The runtime and subscriber stay independently accessible through +/// [`runtime_mut`](Self::runtime_mut) / [`subscriber_mut`](Self::subscriber_mut) +/// for advanced use. One caution: avoid calling +/// [`EventSubscriber::register_interests`] (the full-replacement setup API) on +/// an engine-managed subscriber — implementations may clear owner-scoped +/// bookkeeping, after which per-handler unregistration no longer releases the +/// handler's transport subscriptions. To bootstrap the subscriber from a +/// runtime that already has handlers, use +/// [`sync_handler_interests`](Self::sync_handler_interests), which registers +/// one owner per handler instead of one unowned blob. +pub struct ReactiveEngine { + runtime: ReactiveRuntime, + subscriber: S, + pending_acknowledgement: Option>, + pending_checkpoint: Option>, + last_checkpoint_block: Option, + last_checkpoint_delivery_token: Option, + last_checkpoint_delivery_witness: Option, + last_subscriber_checkpoint: Option, + checkpoint_identity: Option, } -fn trace_storage_value(trace: &BlockStateDiff, address: Address, slot: U256) -> Option { - trace - .accounts - .iter() - .find(|account| account.address == address) - .and_then(|account| { - account - .storage - .iter() - .find(|entry| entry.slot == slot) - .map(|entry| entry.value) - }) +struct PendingAcknowledgement { + token: SubscriberDeliveryToken, + report: ReactiveBatchReport, } -fn account_field_mask_empty(mask: AccountFieldMask) -> bool { - !mask.balance && !mask.nonce && !mask.code +struct PendingCheckpoint { + metadata: DurableCheckpointMetadata, + delivery_token: Option, + report: ReactiveBatchReport, + saved_to: Option, + staged_generation: u64, } -fn execute_resync_requests(cache: &mut EvmCache, requests: &[ResyncRequest]) -> ResyncReport { - let mut failed = Vec::new(); - let mut storage_groups: Vec = Vec::new(); - let mut account_targets: Vec = Vec::new(); +struct CheckpointStage { + incoming_block: Option, + delivery_token: Option, + delivery_witness: Option, + subscriber_checkpoint: Option, + staged_generation: u64, + report: ReactiveBatchReport, +} - for request in requests { - for target in &request.targets { - match target { - ResyncTarget::StorageSlot { address, slot } => { - push_storage_resync_slot( - &mut storage_groups, - &request.id, - &request.block, - *address, - *slot, - ); - } - ResyncTarget::StorageSlots { address, slots } => { - for slot in slots { - push_storage_resync_slot( - &mut storage_groups, - &request.id, - &request.block, - *address, - *slot, - ); - } - } - ResyncTarget::Account { address, fields } => { - account_targets.push(AccountResyncTarget { - request_id: request.id.clone(), - block: request.block.clone(), - address: *address, - fields: *fields, - }); - } - } - } - } +struct DurableResumePlan { + runtime: DurableRuntimeRestorePlan, + position: SubscriberResumePosition, + delivery_witness: Option, +} - let mut state_updates = Vec::new(); - resolve_trace_resyncs( - cache, - &mut storage_groups, - &mut account_targets, - &mut state_updates, - ); +enum HandlerRegistrationCatchup { + LiveOnly, + OwnerBackfill(SubscriberBackfill), + CoordinatedCanonical(BlockRef), +} - if !storage_groups.is_empty() { - if let Some(fetcher) = cache.storage_batch_fetcher().cloned() { - for group in storage_groups { - let block = group.block.clone(); - let fetches: Vec<(Address, U256)> = group - .slots - .iter() - .map(|slot| (slot.address, slot.slot)) - .collect(); - let results = (fetcher)(fetches, resync_block_to_block_id(&block)); - let mut pending: HashMap<(Address, U256), StorageFetchSlot> = group - .slots - .iter() - .cloned() - .map(|slot| ((slot.address, slot.slot), slot)) - .collect(); +const DELIVERY_WITNESS_VERSION: u32 = 1; +const DELIVERY_WITNESS_DOMAIN: &[u8] = b"evm-fork-cache/reactive-delivery-witness"; + +#[derive(serde::Serialize)] +struct DeliveryWitnessEnvelope<'a> { + version: u32, + chain_id: Option, + records: Vec>, + chain_controls: &'a [ChainControl], + subscriber_checkpoint: Option<&'a [u8]>, + payload_commitment: Option, +} - for (address, slot, fetched) in results { - let Some(requested_slot) = pending.remove(&(address, slot)) else { - continue; - }; - match fetched { - Ok(value) => state_updates.push(StateUpdate::slot(address, slot, value)), - Err(error) => { - let message = error.to_string(); - push_resync_failures( - &mut failed, - &block, - requested_slot.origins, - ResyncFailureKind::StorageFetchFailed, - message, - ); - } - } - } +#[derive(serde::Serialize)] +struct DeliveryRecordWitness<'a> { + identity: ReactiveInputIdentity, + context: &'a ReactiveContext, + audience: &'a DeliveryAudience, + scope: DeliveryScope, + payload: DeliveryPayloadWitness<'a>, +} + +#[derive(serde::Serialize)] +enum DeliveryPayloadWitness<'a> { + /// Logs are the primary state-bearing event representation, so retain every + /// RPC payload field in addition to the validated identity/context. + Log { + address: Address, + topics: &'a [B256], + data: &'a Bytes, + block_hash: Option, + block_number: Option, + block_timestamp: Option, + transaction_hash: Option, + transaction_index: Option, + log_index: Option, + removed: bool, + }, + /// Network-generic response bodies do not expose one stable complete serde + /// contract. Their validated identity/context are witnessed here; batches + /// containing headers, full blocks, or hydrated transactions additionally + /// require the source's exact canonical wire-payload commitment. A generic + /// header response can expose a supplied hash without proving that every + /// handler-visible inner field recomputes to it. + IdentityCommitted, +} - for requested_slot in group.slots { - if pending - .remove(&(requested_slot.address, requested_slot.slot)) - .is_some() - { - push_resync_failures( - &mut failed, - &block, - requested_slot.origins, - ResyncFailureKind::StorageFetchOmitted, - "storage batch fetcher did not return a value for slot".to_string(), - ); - } - } - } - } else { - for group in storage_groups { - let block = group.block.clone(); - for slot in group.slots { - push_resync_failures( - &mut failed, - &block, - slot.origins, - ResyncFailureKind::MissingStorageFetcher, - "storage resync requires a storage batch fetcher".to_string(), - ); - } - } +fn durable_delivery_witness( + batch: &ReactiveInputBatch, +) -> Result { + let requires_payload_commitment = batch.records.iter().any(|record| { + matches!( + &record.input, + ReactiveInput::BlockHeader(_) + | ReactiveInput::FullBlock(_) + | ReactiveInput::PendingTx(_) + ) + }); + if requires_payload_commitment && batch.payload_commitment.is_none() { + return Err(ReactiveEngineError::MissingPayloadCommitment); + } + let records = batch + .records + .iter() + .enumerate() + .map(|(index, record)| { + let payload = match &record.input { + ReactiveInput::Log(log) => DeliveryPayloadWitness::Log { + address: log.address(), + topics: log.topics(), + data: &log.inner.data.data, + block_hash: log.block_hash, + block_number: log.block_number, + block_timestamp: log.block_timestamp, + transaction_hash: log.transaction_hash, + transaction_index: log.transaction_index, + log_index: log.log_index, + removed: log.removed, + }, + ReactiveInput::BlockHeader(_) + | ReactiveInput::FullBlock(_) + | ReactiveInput::PendingTxHash(_) + | ReactiveInput::PendingTx(_) => DeliveryPayloadWitness::IdentityCommitted, + }; + Ok(DeliveryRecordWitness { + identity: record.validated_identity()?, + context: &record.context, + audience: batch + .record_audience(index) + .expect("enumerated record always has an audience"), + scope: batch + .record_delivery_scope(index) + .expect("enumerated record always has a delivery scope"), + payload, + }) + }) + .collect::, ReactiveError>>()?; + let envelope = DeliveryWitnessEnvelope { + version: DELIVERY_WITNESS_VERSION, + chain_id: batch.chain_id, + records, + chain_controls: &batch.chain_controls, + subscriber_checkpoint: batch + .subscriber_checkpoint + .as_ref() + .map(SubscriberCheckpoint::as_bytes), + payload_commitment: batch + .payload_commitment + .as_ref() + .map(SubscriberPayloadCommitment::digest), + }; + let encoded = bincode::DefaultOptions::new() + .with_fixint_encoding() + .serialize(&envelope) + .map_err(|error| ReactiveEngineError::DeliveryWitness(error.to_string()))?; + let mut witness = Keccak256::new(); + witness.update(DELIVERY_WITNESS_DOMAIN); + witness.update(encoded); + Ok(witness.finalize()) +} + +impl ReactiveEngine +where + N: Network, + S: EventSubscriber, +{ + /// Bind a runtime and subscriber. + pub fn new(runtime: ReactiveRuntime, subscriber: S) -> Self { + Self { + runtime, + subscriber, + pending_acknowledgement: None, + pending_checkpoint: None, + last_checkpoint_block: None, + last_checkpoint_delivery_token: None, + last_checkpoint_delivery_witness: None, + last_subscriber_checkpoint: None, + checkpoint_identity: None, } } - if !account_targets.is_empty() { - if let Some(fetcher) = cache.account_proof_fetcher().cloned() { - // ONE seam invocation per distinct resync block (targets may pin - // different blocks): eth_getProof is single-address at the RPC - // level, so batching the addresses lets the fetcher fan the - // requests out concurrently instead of paying one round trip per - // account. Root-only probes: account fields need no storage keys. - let mut groups: Vec<(BlockId, Vec<_>)> = Vec::new(); - for account in account_targets { - let block_id = resync_block_to_block_id(&account.block); - match groups - .iter_mut() - .find(|(group_block, _)| *group_block == block_id) - { - Some((_, group)) => group.push(account), - None => groups.push((block_id, vec![account])), - } - } - for (block_id, group) in groups { - let probes: HashMap> = (fetcher)( - group - .iter() - .map(|account| (account.address, vec![])) - .collect(), - block_id, - ) - .into_iter() - .collect(); - for account in group { - // `get` + clone rather than `remove`: two targets for the - // same address in one group must both resolve from the - // single probe. - match probes.get(&account.address).cloned() { - Some(Ok(proof)) => { - // Build an authoritative account update from the requested - // field mask. Use the MATERIALIZING `account_upsert` so a - // resync applies even to a cold account (a partial `Account` - // patch on a cold address is silently skipped). - let mut patch = AccountPatch::default(); - if account.fields.balance { - patch = patch.balance(proof.balance); - } - if account.fields.nonce { - patch = patch.nonce(proof.nonce); - } - // Note: `AccountProof` carries `code_hash`, not code bytes; - // the `eth_getProof` seam cannot supply runtime code, so a - // code-field resync is a no-op here (code freshness is - // handled by a later wave). We still materialize the account - // so requested balance/nonce fields take effect. - state_updates.push(StateUpdate::account_upsert(account.address, patch)); - } - Some(Err(error)) => { - failed.push(ResyncFailure { - request_id: account.request_id, - block: account.block, - target: ResyncTarget::Account { - address: account.address, - fields: account.fields, - }, - kind: ResyncFailureKind::AccountFetchFailed, - message: error.to_string(), - }); - } - None => { - failed.push(ResyncFailure { - request_id: account.request_id, - block: account.block, - target: ResyncTarget::Account { - address: account.address, - fields: account.fields, - }, - kind: ResyncFailureKind::AccountFetchOmitted, - message: - "account proof fetcher did not return a result for address" - .to_string(), - }); - } - } - } - } - } else { - for account in account_targets { - failed.push(ResyncFailure { - request_id: account.request_id, - block: account.block, - target: ResyncTarget::Account { - address: account.address, - fields: account.fields, - }, - kind: ResyncFailureKind::MissingAccountFetcher, - message: "account resync requires an account proof fetcher".to_string(), - }); - } + /// Split the engine into its runtime and subscriber parts when no commit is + /// pending. + /// + /// A failed delivery acknowledgement or durable checkpoint commit remains + /// live protocol state: dropping it would allow the caller to lose the + /// already-applied report/token pair and poll past an uncommitted batch. + /// In that case this returns the intact engine so the caller can repair the + /// dependency and retry through the normal ingestion method. + /// + /// # Errors + /// + /// Returns the intact boxed engine when an acknowledgement or checkpoint + /// commit is pending. + pub fn into_parts(self) -> Result<(ReactiveRuntime, S), Box> { + if self.pending_acknowledgement.is_some() || self.pending_checkpoint.is_some() { + return Err(Box::new(self)); } + Ok((self.runtime, self.subscriber)) } - let diff = if state_updates.is_empty() { - StateDiff::default() - } else { - cache.apply_updates(&state_updates) - }; + fn durable_resume_plan( + &self, + metadata: &DurableCheckpointMetadata, + ) -> Result { + if !self.subscriber.capabilities().supports_durable_replay() { + return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable); + } + self.ensure_subscriber_restore_chain(metadata.identity.chain_id)?; + if !self.runtime.is_pristine_for_checkpoint_restore() + || self.pending_acknowledgement.is_some() + || self.pending_checkpoint.is_some() + || self.last_checkpoint_block.is_some() + || self.last_checkpoint_delivery_token.is_some() + || self.last_checkpoint_delivery_witness.is_some() + || self.last_subscriber_checkpoint.is_some() + || self.checkpoint_identity.is_some() + { + return Err(ReactiveCheckpointRestoreError::ActiveRuntime); + } - ResyncReport { - requested: requests.to_vec(), - state_updates, - diff, - failed, + let block = BlockRef { + number: metadata.block.number, + hash: metadata.block.hash, + parent_hash: metadata.block.parent_hash, + timestamp: metadata.block.timestamp, + }; + let runtime = match metadata.runtime_checkpoint.as_deref() { + Some(bytes) => self + .runtime + .plan_durable_checkpoint_restore(bytes, &block)?, + None => DurableRuntimeRestorePlan { + checkpoint: None, + fallback_history: (self.runtime.config.journal_depth > 0) + .then_some(block) + .into_iter() + .collect(), + }, + }; + let delivery_token = metadata + .delivery_token + .clone() + .map(SubscriberDeliveryToken::new); + let subscriber_checkpoint = metadata + .subscriber_checkpoint + .clone() + .map(SubscriberCheckpoint::new); + let position = SubscriberResumePosition::new( + metadata.identity.chain_id, + block, + runtime.canonical_history(), + delivery_token, + subscriber_checkpoint, + ); + Ok(DurableResumePlan { + runtime, + position, + delivery_witness: metadata.delivery_witness, + }) } -} -fn push_resync_failures( - failed: &mut Vec, - block: &ResyncBlock, - origins: Vec, - kind: ResyncFailureKind, - message: String, -) { - for origin in origins { - failed.push(ResyncFailure { - request_id: origin.request_id, - block: block.clone(), - target: origin.target, - kind, - message: message.clone(), - }); + /// Preview the exact subscriber position a durable restore will install. + /// + /// This read-only step exists for durable subscribers that must complete + /// asynchronous source or transport preparation before the engine invokes + /// the synchronous [`EventSubscriber::restore_position`] hook. It decodes + /// and validates the core runtime checkpoint, applies this runtime's + /// configured journal retention to the preview, and returns the same + /// [`SubscriberResumePosition`] that + /// [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint) + /// will later pass to the subscriber. + /// + /// Call this on the same fresh engine that will perform the restore. After + /// subscriber preparation completes, pass the identical `metadata` to + /// `resume_from_durable_checkpoint` (or restore the same loaded checkpoint + /// through [`restore_durable_checkpoint`](Self::restore_durable_checkpoint)) + /// without mutating engine runtime or checkpoint state in between. The + /// checkpoint identity and, for non-finalized state, its canonical block + /// must still be validated by the caller before external preparation. + /// + /// This method does not mutate the runtime, subscriber, or checkpoint + /// bookkeeping. + /// + /// # Errors + /// + /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not + /// durable, its chain identity conflicts with the checkpoint, the engine is + /// not fresh, or the stored runtime checkpoint is malformed, unsupported, + /// or internally inconsistent. + pub fn preview_durable_resume_position( + &self, + metadata: &DurableCheckpointMetadata, + ) -> Result { + Ok(self.durable_resume_plan(metadata)?.position) } -} -fn push_storage_resync_slot( - groups: &mut Vec, - request_id: &ResyncId, - block: &ResyncBlock, - address: Address, - slot: U256, -) { - let group_index = if let Some(index) = groups.iter().position(|group| group.block == *block) { - index - } else { - groups.push(StorageFetchGroup { - block: block.clone(), - slots: Vec::new(), - seen: HashSet::new(), - }); - groups.len() - 1 - }; + /// Resume delivery bookkeeping and canonical continuity from a cache + /// checkpoint that has already been identity- and hash-validated and + /// restored into [`EvmCache`]. + /// + /// Call this on a fresh engine. The anchor has no rollback effects of its + /// own: it represents the state baseline embodied by the checkpoint, while + /// newly ingested blocks are journaled normally above it. + /// The subscriber must advertise [`SubscriberCapability::DurableReplay`]; + /// restoring an ephemeral stream would claim a restart guarantee it cannot + /// uphold and is rejected before cache or runtime mutation. + /// + /// Prefer [`restore_durable_checkpoint`](Self::restore_durable_checkpoint) + /// when the cache has not yet been restored: that helper rolls the cache + /// back as well if runtime or subscriber activation fails. + /// + /// # Errors + /// + /// Returns [`ReactiveCheckpointRestoreError`] when the subscriber is not + /// durable, chain identity conflicts, the runtime is not pristine, stored + /// runtime state is invalid, or the subscriber rejects the restored + /// position. Runtime state is restored on subscriber failure. + pub fn resume_from_durable_checkpoint( + &mut self, + metadata: &DurableCheckpointMetadata, + ) -> Result<(), ReactiveCheckpointRestoreError> { + let plan = self.durable_resume_plan(metadata)?; + let prior_runtime = self.runtime.checkpoint_state(); - let group = &mut groups[group_index]; - let origin = StorageFetchOrigin { - request_id: request_id.clone(), - target: ResyncTarget::StorageSlot { address, slot }, - }; - if group.seen.insert((address, slot)) { - group.slots.push(StorageFetchSlot { - address, - slot, - origins: vec![origin], - }); - } else if let Some(existing) = group - .slots - .iter_mut() - .find(|existing| existing.address == address && existing.slot == slot) - { - existing.origins.push(origin); + let DurableResumePlan { + runtime, + position, + delivery_witness, + } = plan; + self.runtime.apply_durable_checkpoint_restore(runtime); + self.runtime.coverage_head = Some(position.coverage_head); + if let Err(error) = self.subscriber.restore_position(&position) { + self.runtime.restore_state(prior_runtime); + return Err(ReactiveCheckpointRestoreError::Subscriber(error)); + } + if let Err(error) = self.ensure_subscriber_restore_chain(metadata.identity.chain_id) { + self.runtime.restore_state(prior_runtime); + return Err(error); + } + self.last_checkpoint_block = Some(metadata.block.clone()); + self.last_checkpoint_delivery_token = position.delivery_token; + self.last_checkpoint_delivery_witness = delivery_witness; + self.last_subscriber_checkpoint = position.subscriber_checkpoint; + self.checkpoint_identity = Some(metadata.identity.clone()); + Ok(()) } -} -fn resync_block_to_block_id(block: &ResyncBlock) -> BlockId { - match block { - ResyncBlock::Latest => BlockId::latest(), - ResyncBlock::Safe => BlockId::safe(), - ResyncBlock::Finalized => BlockId::finalized(), - ResyncBlock::Number(number) => BlockId::number(*number), - ResyncBlock::Hash { - number: _, - hash, - require_canonical, - } => BlockId::from((*hash, Some(*require_canonical))), + /// Atomically restore cache, runtime, and subscriber position from one + /// validated durable checkpoint. + /// + /// Inspect [`LoadedDurableCheckpoint::metadata`] and validate its canonical + /// block against an authoritative RPC source before calling this method when + /// the block is not finalized. Identity, cache-chain, runtime-state, and + /// subscriber failures leave the cache and engine runtime unchanged. The + /// subscriber follows [`EventSubscriber::restore_position`]'s retry contract. + /// It must advertise [`SubscriberCapability::DurableReplay`]. + /// + /// # Errors + /// + /// Returns [`ReactiveCheckpointRestoreError`] for checkpoint identity, + /// cache-chain, runtime-state, subscriber-capability, subscriber-chain, or + /// position-restore failures. Cache and runtime state remain unchanged. + pub fn restore_durable_checkpoint( + &mut self, + cache: &mut EvmCache, + loaded: LoadedDurableCheckpoint, + expected: &DurableCheckpointIdentity, + ) -> Result { + if !self.subscriber.capabilities().supports_durable_replay() { + return Err(ReactiveCheckpointRestoreError::SubscriberNotDurable); + } + self.ensure_subscriber_restore_chain(expected.chain_id)?; + if !self.runtime.is_pristine_for_checkpoint_restore() + || self.pending_acknowledgement.is_some() + || self.pending_checkpoint.is_some() + || self.last_checkpoint_block.is_some() + || self.last_checkpoint_delivery_token.is_some() + || self.last_checkpoint_delivery_witness.is_some() + || self.last_subscriber_checkpoint.is_some() + || self.checkpoint_identity.is_some() + { + return Err(ReactiveCheckpointRestoreError::ActiveRuntime); + } + + let prior_cache = EvmCacheStateSnapshot::capture(cache); + let metadata = loaded.restore_into(cache, expected)?; + if let Err(error) = self.resume_from_durable_checkpoint(&metadata) { + prior_cache.restore(cache); + return Err(error); + } + Ok(metadata) } -} -impl RegisteredHandler { - fn matches(&self, input: &ReactiveInput) -> bool { - self.interests - .iter() - .any(|interest| interest_matches(interest, input)) + /// Borrow the runtime. + pub fn runtime(&self) -> &ReactiveRuntime { + &self.runtime } - fn route_log(&self, log: &Log) -> Option { - self.interests.iter().find_map(|interest| match interest { - ReactiveInterest::Logs(interest) if interest.matches(log) => Some(ReactiveLogRoute { - handler_id: self.id.clone(), - route_key: interest.route_key(log), - }), - ReactiveInterest::Logs(_) - | ReactiveInterest::Blocks(_) - | ReactiveInterest::PendingTransactions(_) => None, - }) + /// Mutably borrow the runtime. + pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime { + &mut self.runtime } -} -fn merge_log_subscription_filter(filters: &mut Vec, next: &Filter) { - if let Some(existing) = filters - .iter_mut() - .find(|existing| existing.block_option == next.block_option) - { - merge_filter_set(&mut existing.address, &next.address); - for (existing_topic, next_topic) in existing.topics.iter_mut().zip(next.topics.iter()) { - merge_filter_set(existing_topic, next_topic); - } - } else { - filters.push(next.clone()); + /// Borrow the subscriber. + pub fn subscriber(&self) -> &S { + &self.subscriber } -} -fn merge_filter_set(target: &mut FilterSet, source: &FilterSet) { - if target.is_empty() { - return; + /// Mutably borrow the subscriber. + pub fn subscriber_mut(&mut self) -> &mut S { + &mut self.subscriber } - if source.is_empty() { - *target = FilterSet::default(); - return; + + /// Adopt a hash-pinned RPC cache snapshot as the runtime's canonical + /// cold-start baseline. + /// + /// The cache must use the exact canonical hash selector and block-number + /// context named by `baseline`; when the baseline includes a timestamp, the + /// cache timestamp must match too. Cache, baseline, and any already-resolved + /// subscriber identity must name the same chain. No delivery or checkpoint + /// commit may be pending. After this succeeds, call + /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill) + /// before polling: it exact-replaces subscriber owners and begins event + /// catch-up at `C + 1`. + /// + /// # Errors + /// + /// Returns [`ReactiveEngineError`] when commit state is pending, the runtime + /// is active or already has a conflicting baseline, cache/subscriber chain + /// identity differs, or the cache is not pinned to the exact baseline. + pub fn adopt_canonical_baseline( + &mut self, + cache: &EvmCache, + baseline: ReactiveCanonicalBaseline, + ) -> Result<(), ReactiveEngineError> { + if self.pending_acknowledgement.is_some() + || self.pending_checkpoint.is_some() + || self.last_checkpoint_block.is_some() + || self.last_checkpoint_delivery_token.is_some() + || self.last_checkpoint_delivery_witness.is_some() + || self.last_subscriber_checkpoint.is_some() + || self.checkpoint_identity.is_some() + { + return Err(ReactiveBaselineError::ActiveRuntime.into()); + } + // Establish deterministic lifecycle/idempotency semantics before + // consulting mutable cache context. A conflicting repeat is a runtime + // baseline conflict even if the caller also repointed the cache. + self.runtime + .validate_canonical_baseline_adoption(baseline.block)?; + if baseline.chain_id != cache.chain_id() { + return Err(ReactiveBaselineError::CacheChainMismatch { + baseline_chain_id: baseline.chain_id, + cache_chain_id: cache.chain_id(), + } + .into()); + } + self.ensure_subscriber_chain(cache)?; + let exact_selector = BlockId::from((baseline.block.hash, Some(true))); + let context_matches = cache.block_number() == Some(baseline.block.number) + && baseline + .block + .timestamp + .is_none_or(|timestamp| cache.timestamp() == Some(timestamp)); + if cache.block() != exact_selector || !context_matches { + return Err(ReactiveBaselineError::CacheBlockMismatch { + number: baseline.block.number, + hash: baseline.block.hash, + } + .into()); + } + self.runtime.adopt_canonical_baseline(baseline.block)?; + Ok(()) } - for value in source.iter() { - target.insert(value.clone()); + + /// Poll the subscriber for the next batch without ingesting it. + /// + /// This low-level escape hatch is unavailable while the engine owes an + /// acknowledgement or checkpoint commit. Callers that use it must return + /// any subscriber-owned delivery metadata through a combined + /// [`next_ingest`](Self::next_ingest) helper; raw ingestion deliberately + /// rejects that metadata so it cannot be discarded accidentally. + /// + /// # Errors + /// + /// Returns [`ReactiveEngineError`] when an acknowledgement/checkpoint commit + /// is pending or subscriber and cache chain identities conflict. + pub fn next_batch( + &mut self, + cache: &EvmCache, + ) -> Result, ReactiveEngineError> { + if self.pending_checkpoint.is_some() { + return Err(ReactiveEngineError::PendingCheckpointCommit); + } + if self.pending_acknowledgement.is_some() { + return Err(ReactiveEngineError::PendingAcknowledgementCommit); + } + self.ensure_subscriber_chain(cache)?; + Ok(self.subscriber.next_batch()) } -} -#[derive(Clone, Debug)] -struct HandlerExecution { - handler_id: HandlerId, - quality: StateEffectQuality, - tags: Vec, - state_updates: Vec, - invalidations: Vec, - resyncs: Vec, - speculative: Vec, - hook_signals: Vec, -} + /// Ingest one already-polled batch through the runtime (direct effects + /// only; surfaced resync requests are reported, not executed). + /// + /// # Errors + /// + /// Returns [`ReactiveEngineError`] when commit state is pending, the batch + /// carries subscriber-owned commit metadata, chain identity conflicts, or + /// runtime ingestion fails. + pub fn ingest_batch( + &mut self, + cache: &mut EvmCache, + batch: ReactiveInputBatch, + ) -> Result, ReactiveEngineError> { + self.ensure_raw_ingest_is_safe(cache, &batch)?; + Ok(self.runtime.ingest_batch(cache, batch)?) + } -impl HandlerExecution { - fn from_outcome(handler_id: HandlerId, input_ref: InputRef, outcome: HandlerOutcome) -> Self { - let mut state_updates = Vec::new(); - let mut invalidations = Vec::new(); - let mut resyncs = Vec::new(); - let mut speculative = Vec::new(); - let mut hook_signals = Vec::new(); + /// Ingest one already-polled batch and execute the storage/account resyncs + /// it surfaces, exactly like + /// [`ReactiveRuntime::ingest_batch_with_resync`]. + /// + /// # Errors + /// + /// Returns [`ReactiveEngineError`] when commit state is pending, the batch + /// carries subscriber-owned commit metadata, chain identity conflicts, or + /// runtime ingestion fails. + pub fn ingest_batch_with_resync( + &mut self, + cache: &mut EvmCache, + batch: ReactiveInputBatch, + ) -> Result, ReactiveEngineError> { + self.ensure_raw_ingest_is_safe(cache, &batch)?; + Ok(self.runtime.ingest_batch_with_resync(cache, batch)?) + } - for effect in outcome.effects { - match effect { - ReactiveEffect::StateUpdate(update) => state_updates.push(update), - ReactiveEffect::Invalidate(invalidation) => { - state_updates.push(StateUpdate::purge( - invalidation.address, - invalidation.scope.clone(), - )); - invalidations.push(invalidation); - } - ReactiveEffect::Resync(request) => resyncs.push(request), - ReactiveEffect::Hook(signal) => hook_signals.push(signal), - ReactiveEffect::Speculative(mut request) => { - request.input_ref = input_ref; - speculative.push(request); - } - } + fn ensure_raw_ingest_is_safe( + &self, + cache: &EvmCache, + batch: &ReactiveInputBatch, + ) -> Result<(), ReactiveEngineError> { + if self.pending_checkpoint.is_some() { + return Err(ReactiveEngineError::PendingCheckpointCommit); } - - Self { - handler_id, - quality: outcome.quality, - tags: outcome.tags, - state_updates, - invalidations, - resyncs, - speculative, - hook_signals, + if self.pending_acknowledgement.is_some() { + return Err(ReactiveEngineError::PendingAcknowledgementCommit); + } + if batch.delivery_token().is_some() || batch.subscriber_checkpoint().is_some() { + return Err(ReactiveEngineError::UncommittedDeliveryMetadata); } + self.ensure_subscriber_chain(cache)?; + Ok(()) } -} -fn dedupe_records(records: Vec>) -> Vec> { - let mut seen = HashSet::new(); - let mut deduped = Vec::with_capacity(records.len()); - for record in records { - if seen.insert(record.input_ref()) { - deduped.push(record); + fn ensure_subscriber_chain(&self, cache: &EvmCache) -> Result<(), ReactiveEngineError> { + if let Some(subscriber_chain_id) = self.subscriber.chain_id() + && subscriber_chain_id != cache.chain_id() + { + return Err(ReactiveEngineError::SubscriberChainMismatch { + subscriber_chain_id, + cache_chain_id: cache.chain_id(), + }); } + Ok(()) } - deduped -} -fn sort_records(records: Vec>) -> Vec> { - let mut indexed: Vec<(usize, ReactiveInputRecord)> = - records.into_iter().enumerate().collect(); - indexed.sort_by_key(|(index, record)| record_sort_key(*index, record)); - indexed.into_iter().map(|(_, record)| record).collect() -} + fn ensure_subscriber_restore_chain( + &self, + checkpoint_chain_id: u64, + ) -> Result<(), ReactiveCheckpointRestoreError> { + if let Some(subscriber_chain_id) = self.subscriber.chain_id() + && subscriber_chain_id != checkpoint_chain_id + { + return Err(ReactiveCheckpointRestoreError::SubscriberChainMismatch { + subscriber_chain_id, + checkpoint_chain_id, + }); + } + Ok(()) + } -fn record_sort_key(index: usize, record: &ReactiveInputRecord) -> RecordSortKey { - if let ReactiveInput::Log(log) = &record.input - && is_canonical_status(&record.context.chain_status) - && !log.removed - { - return RecordSortKey { - class: 0, - block_number: log - .block_number - .or(record.context.block.as_ref().map(|block| block.number)) - .unwrap_or(u64::MAX), - transaction_index: log - .transaction_index - .or(record.context.transaction_index) - .unwrap_or(u64::MAX), - log_index: log - .log_index - .or(record.context.log_index) - .unwrap_or(u64::MAX), - original_index: index, + /// Poll the subscriber once and ingest the returned batch when present + /// (direct effects only). + /// + /// # Errors + /// + /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch, + /// pending checkpoint state, subscriber polling, runtime ingestion, or + /// delivery-acknowledgement failure. A failed acknowledgement remains + /// pending and is retried before polling again. + pub async fn next_ingest( + &mut self, + cache: &mut EvmCache, + ) -> Result>, ReactiveEngineError> { + self.ensure_subscriber_chain(cache)?; + if self.pending_checkpoint.is_some() { + return Err(ReactiveEngineError::PendingCheckpointCommit); + } + if self.pending_acknowledgement.is_some() { + return self.commit_pending_acknowledgement().await.map(Some); + } + let batch = self.subscriber.next_batch().await?; + self.ensure_subscriber_chain(cache)?; + let Some(mut batch) = batch else { + return Ok(None); }; + let delivery_token = batch.take_delivery_token(); + let report = self.runtime.ingest_batch(cache, batch)?; + self.stage_or_return_acknowledgement(delivery_token, report) + .await } - RecordSortKey { - class: 1, - block_number: 0, - transaction_index: 0, - log_index: 0, - original_index: index, + /// Poll the subscriber once and ingest the returned batch with resync + /// execution — the loop shape for consumers that rely on coverage-gap + /// repair (root-gate resyncs, handler-requested re-reads). + /// + /// # Errors + /// + /// Returns [`ReactiveEngineError`] for subscriber/cache chain mismatch, + /// pending checkpoint state, subscriber polling, runtime ingestion, or + /// delivery-acknowledgement failure. A failed acknowledgement remains + /// pending and is retried before polling again. + pub async fn next_ingest_with_resync( + &mut self, + cache: &mut EvmCache, + ) -> Result>, ReactiveEngineError> { + self.ensure_subscriber_chain(cache)?; + if self.pending_checkpoint.is_some() { + return Err(ReactiveEngineError::PendingCheckpointCommit); + } + if self.pending_acknowledgement.is_some() { + return self.commit_pending_acknowledgement().await.map(Some); + } + let batch = self.subscriber.next_batch().await?; + self.ensure_subscriber_chain(cache)?; + let Some(mut batch) = batch else { + return Ok(None); + }; + let delivery_token = batch.take_delivery_token(); + let report = self.runtime.ingest_batch_with_resync(cache, batch)?; + self.stage_or_return_acknowledgement(delivery_token, report) + .await } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct RecordSortKey { - class: u8, - block_number: u64, - transaction_index: u64, - log_index: u64, - original_index: usize, -} + /// Poll, ingest, atomically checkpoint, then acknowledge one batch. + /// + /// The ordering is strict: subscriber acknowledgement is never attempted + /// until the complete cache checkpoint is synced. If checkpointing or + /// acknowledgement fails, the in-memory pending commit is retried before + /// any later batch is polled, so a transient disk failure cannot cause the + /// already-applied batch to execute twice in the same process. Across a + /// process restart, [`resume_from_durable_checkpoint`](Self::resume_from_durable_checkpoint) + /// uses the stored delivery token and delivery witness to recognize and + /// acknowledge an identical replay without re-ingestion. Reusing a token + /// for different input or cursor state fails closed. Mutating the cache while + /// a commit is pending also fails closed rather than binding newer state to + /// older delivery metadata. Any explicit, implicit, or removed-log reorg + /// that cannot be proven from the retained effect journal is rejected before + /// mutation/save/ACK; configure + /// [`ReactiveConfig::journal_depth`] to cover the subscriber's reorg horizon. + /// Hooks are dispatched only after checkpoint staging + /// succeeds, but remain in-process observers rather than a durable outbox; + /// see [`ReactiveHook`]. The subscriber must advertise + /// [`SubscriberCapability::DurableReplay`]; ephemeral subscribers are + /// rejected before polling. + /// + /// # Errors + /// + /// Returns [`ReactiveEngineError`] when the subscriber lacks durable replay, + /// identities or replay witnesses conflict, a checkpoint/ACK is already in + /// an incompatible state, polling or ingestion fails, complete rollback + /// proof is unavailable, the cache changes after staging, persistence + /// fails, or delivery acknowledgement fails. Pending checkpoint/ACK work is + /// retained for retry before another poll. + pub async fn next_ingest_checkpointed( + &mut self, + cache: &mut EvmCache, + store: &DurableCheckpointStore, + identity: &DurableCheckpointIdentity, + ) -> Result>, ReactiveEngineError> { + if !self.subscriber.capabilities().supports_durable_replay() { + return Err(ReactiveEngineError::SubscriberNotDurable); + } + self.ensure_subscriber_chain(cache)?; + if self.pending_acknowledgement.is_some() { + return Err(ReactiveEngineError::PendingAcknowledgementCommit); + } + self.ensure_checkpoint_identity(cache, identity)?; + if self.pending_checkpoint.is_some() { + return self.commit_pending_checkpoint(cache, store).await.map(Some); + } -fn interest_matches(interest: &ReactiveInterest, input: &ReactiveInput) -> bool { - match (interest, input) { - (ReactiveInterest::Logs(interest), ReactiveInput::Log(log)) => interest.matches(log), - ( - ReactiveInterest::Blocks(BlockInterest { - mode: BlockInterestMode::Header, - }), - ReactiveInput::BlockHeader(_), - ) => true, - ( - ReactiveInterest::Blocks(BlockInterest { - mode: BlockInterestMode::FullBlock, - }), - ReactiveInput::FullBlock(_), - ) => true, - (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTxHash(_)) => { - interest.matches_hash_only() + let batch = self.subscriber.next_batch().await?; + self.ensure_subscriber_chain(cache)?; + let Some(mut batch) = batch else { + return Ok(None); + }; + if batch_preconfirmation(&batch)?.is_some() { + return Err(ReactiveEngineError::PreconfirmationNotCheckpointable); } - (ReactiveInterest::PendingTransactions(interest), ReactiveInput::PendingTx(tx)) => { - interest.matches_tx(tx) + self.runtime.discard_preconfirmed_branch(cache); + let delivery_witness = batch + .delivery_token() + .map(|_| durable_delivery_witness(&batch)) + .transpose()?; + let delivery_token = batch.take_delivery_token(); + let subscriber_checkpoint = batch.take_subscriber_checkpoint(); + if let (Some(replay_token), Some(committed_token)) = ( + delivery_token.as_ref(), + self.last_checkpoint_delivery_token.as_ref(), + ) && replay_token == committed_token + { + let committed_witness = self + .last_checkpoint_delivery_witness + .ok_or(ReactiveEngineError::MissingReplayWitness)?; + if delivery_witness != Some(committed_witness) { + return Err(ReactiveEngineError::ReplayDeliveryMismatch); + } + self.subscriber + .acknowledge_delivery(replay_token.clone()) + .await + .map_err(ReactiveEngineError::Acknowledgement)?; + return Ok(Some(CheckpointedIngest::ReplayAcknowledged)); } - _ => false, - } -} -fn validate_effects( - input_ref: InputRef, - ctx: &ReactiveContext, - handler_id: &HandlerId, - effects: &[ReactiveEffect], -) -> Result<(), ReactiveError> { - let pending = matches!(ctx.chain_status, ChainStatus::Pending) - || matches!(input_ref, InputRef::PendingTx { .. }); - if !pending { - return Ok(()); + self.ensure_checkpointable_reorgs(&batch)?; + + let incoming_block = latest_canonical_batch_block(&batch); + let cache_state = EvmCacheStateSnapshot::capture(cache); + let runtime_state = self.runtime.checkpoint_state(); + let report = match self.runtime.ingest_batch_direct(cache, batch) { + Ok(report) => report, + Err(error) => { + cache_state.restore(cache); + self.runtime.restore_transaction_state(runtime_state); + return Err(error.into()); + } + }; + let reports = report.reports.clone(); + let stage = CheckpointStage { + incoming_block, + delivery_token, + delivery_witness, + subscriber_checkpoint, + staged_generation: cache.snapshot_generation(), + report, + }; + if let Err(error) = self.stage_checkpoint(identity, stage) { + cache_state.restore(cache); + self.runtime.restore_transaction_state(runtime_state); + return Err(error); + } + self.runtime.dispatch_reports(&reports); + self.commit_pending_checkpoint(cache, store).await.map(Some) } - for effect in effects { - let effect_kind = match effect { - ReactiveEffect::StateUpdate(_) => Some("state_update"), - ReactiveEffect::Invalidate(_) => Some("invalidate"), - ReactiveEffect::Resync(_) => Some("resync"), - ReactiveEffect::Hook(_) | ReactiveEffect::Speculative(_) => None, + /// Checkpointed counterpart to [`next_ingest_with_resync`](Self::next_ingest_with_resync). + /// Requires [`SubscriberCapability::DurableReplay`] and rejects an + /// ephemeral subscriber before polling. + /// + /// # Errors + /// + /// Returns [`ReactiveEngineError`] for the same durability, identity, + /// rollback-proof, replay-witness, polling, ingestion, persistence, + /// mutation-fence, and acknowledgement failures as + /// [`next_ingest_checkpointed`](Self::next_ingest_checkpointed). + pub async fn next_ingest_with_resync_checkpointed( + &mut self, + cache: &mut EvmCache, + store: &DurableCheckpointStore, + identity: &DurableCheckpointIdentity, + ) -> Result>, ReactiveEngineError> { + if !self.subscriber.capabilities().supports_durable_replay() { + return Err(ReactiveEngineError::SubscriberNotDurable); + } + self.ensure_subscriber_chain(cache)?; + if self.pending_acknowledgement.is_some() { + return Err(ReactiveEngineError::PendingAcknowledgementCommit); + } + self.ensure_checkpoint_identity(cache, identity)?; + if self.pending_checkpoint.is_some() { + return self.commit_pending_checkpoint(cache, store).await.map(Some); + } + + let batch = self.subscriber.next_batch().await?; + self.ensure_subscriber_chain(cache)?; + let Some(mut batch) = batch else { + return Ok(None); }; - if let Some(effect_kind) = effect_kind { - return Err(ReactiveError::InvalidPendingEffect { - input_ref: Box::new(input_ref), - handler_id: handler_id.clone(), - effect_kind, - }); + if batch_preconfirmation(&batch)?.is_some() { + return Err(ReactiveEngineError::PreconfirmationNotCheckpointable); + } + self.runtime.discard_preconfirmed_branch(cache); + let delivery_witness = batch + .delivery_token() + .map(|_| durable_delivery_witness(&batch)) + .transpose()?; + let delivery_token = batch.take_delivery_token(); + let subscriber_checkpoint = batch.take_subscriber_checkpoint(); + if let (Some(replay_token), Some(committed_token)) = ( + delivery_token.as_ref(), + self.last_checkpoint_delivery_token.as_ref(), + ) && replay_token == committed_token + { + let committed_witness = self + .last_checkpoint_delivery_witness + .ok_or(ReactiveEngineError::MissingReplayWitness)?; + if delivery_witness != Some(committed_witness) { + return Err(ReactiveEngineError::ReplayDeliveryMismatch); + } + self.subscriber + .acknowledge_delivery(replay_token.clone()) + .await + .map_err(ReactiveEngineError::Acknowledgement)?; + return Ok(Some(CheckpointedIngest::ReplayAcknowledged)); } - } - Ok(()) -} -fn detect_conflicts( - input_ref: InputRef, - executions: &[HandlerExecution], -) -> Result<(), ReactiveError> { - let mut writes: HashMap = HashMap::new(); - for execution in executions { - for update in &execution.state_updates { - for (target, value) in absolute_writes(update) { - if let Some((previous_value, previous_handler)) = writes.get(&target) { - if previous_value != &value { - return Err(ReactiveError::ConflictingEffects { - input_ref: Box::new(input_ref), - target: Box::new(target), - first: previous_handler.clone(), - second: execution.handler_id.clone(), - }); - } - } else { - writes.insert(target, (value, execution.handler_id.clone())); - } + self.ensure_checkpointable_reorgs(&batch)?; + + let incoming_block = latest_canonical_batch_block(&batch); + let cache_state = EvmCacheStateSnapshot::capture(cache); + let runtime_state = self.runtime.checkpoint_state(); + let report = match self.runtime.ingest_batch_with_resync_direct(cache, batch) { + Ok(report) => report, + Err(error) => { + cache_state.restore(cache); + self.runtime.restore_transaction_state(runtime_state); + return Err(error.into()); } + }; + let reports = report.reports.clone(); + let stage = CheckpointStage { + incoming_block, + delivery_token, + delivery_witness, + subscriber_checkpoint, + staged_generation: cache.snapshot_generation(), + report, + }; + if let Err(error) = self.stage_checkpoint(identity, stage) { + cache_state.restore(cache); + self.runtime.restore_transaction_state(runtime_state); + return Err(error); } + self.runtime.dispatch_reports(&reports); + self.commit_pending_checkpoint(cache, store).await.map(Some) } - Ok(()) -} -fn absolute_writes(update: &StateUpdate) -> Vec<(EffectTarget, AbsoluteValue)> { - match update { - StateUpdate::Slot { - address, - slot, - value, - } => vec![( - EffectTarget::StorageSlot { - address: *address, - slot: *slot, - }, - AbsoluteValue::U256(*value), - )], - StateUpdate::SlotMasked { - address, - slot, - mask, - value, - } => vec![( - EffectTarget::MaskedStorageSlot { - address: *address, - slot: *slot, - mask: *mask, - }, - AbsoluteValue::U256(*value), - )], - StateUpdate::Account { address, patch } | StateUpdate::AccountUpsert { address, patch } => { - account_patch_writes(*address, patch) + fn stage_checkpoint( + &mut self, + identity: &DurableCheckpointIdentity, + stage: CheckpointStage, + ) -> Result<(), ReactiveEngineError> { + let CheckpointStage { + incoming_block, + delivery_token, + delivery_witness, + subscriber_checkpoint, + staged_generation, + report, + } = stage; + if delivery_token.is_some() != delivery_witness.is_some() { + return Err(ReactiveEngineError::DeliveryWitness( + "delivery token and witness must be staged together".into(), + )); } - StateUpdate::SlotDelta { .. } - | StateUpdate::BalanceDelta { .. } - | StateUpdate::Purge { .. } => Vec::new(), + let runtime_checkpoint = self.runtime.durable_checkpoint_bytes()?; + let block = self + .runtime + .last_canonical_block() + .map(|block| DurableCheckpointBlock { + number: block.number, + hash: block.hash, + parent_hash: block.parent_hash, + timestamp: block.timestamp, + }) + .or(incoming_block) + .or_else(|| self.last_checkpoint_block.clone()) + .ok_or(ReactiveEngineError::MissingCheckpointBlock)?; + let metadata = DurableCheckpointMetadata { + identity: identity.clone(), + block, + delivery_token: delivery_token + .as_ref() + .or(self.last_checkpoint_delivery_token.as_ref()) + .map(|token| token.as_bytes().to_vec()), + delivery_witness: if delivery_token.is_some() { + delivery_witness + } else { + self.last_checkpoint_delivery_witness + }, + subscriber_checkpoint: subscriber_checkpoint + .as_ref() + .or(self.last_subscriber_checkpoint.as_ref()) + .map(|checkpoint| checkpoint.as_bytes().to_vec()), + runtime_checkpoint: Some(runtime_checkpoint), + }; + self.pending_checkpoint = Some(PendingCheckpoint { + metadata, + delivery_token, + report, + saved_to: None, + staged_generation, + }); + Ok(()) } -} -fn account_patch_writes( - address: Address, - patch: &AccountPatch, -) -> Vec<(EffectTarget, AbsoluteValue)> { - let mut writes = Vec::new(); - if let Some(balance) = patch.balance { - writes.push(( - EffectTarget::AccountBalance { address }, - AbsoluteValue::U256(balance), - )); + fn ensure_checkpointable_reorgs( + &self, + batch: &ReactiveInputBatch, + ) -> Result<(), ReactiveEngineError> { + let state = CanonicalSequenceState::new( + self.runtime + .journal + .iter() + .map(|entry| entry.block) + .collect(), + self.runtime.coverage_head, + self.runtime.safe_head, + self.runtime.finalized_head, + ); + match validate_canonical_sequence_internal( + &state, + batch, + CanonicalSequenceValidationPolicy::RequireCompleteRollback, + ) { + Ok(_) => Ok(()), + Err(CanonicalSequenceError::Invalid(error)) => Err(error.into()), + Err(CanonicalSequenceError::IncompleteRollback { + common_ancestor, + oldest_retained, + .. + }) => Err(ReactiveEngineError::CheckpointReorgOutsideJournal { + common_ancestor, + oldest_journaled: oldest_retained, + journal_depth: self.runtime.config.journal_depth, + }), + } } - if let Some(nonce) = patch.nonce { - writes.push(( - EffectTarget::AccountNonce { address }, - AbsoluteValue::U64(nonce), - )); + + async fn stage_or_return_acknowledgement( + &mut self, + delivery_token: Option, + report: ReactiveBatchReport, + ) -> Result>, ReactiveEngineError> { + let Some(token) = delivery_token else { + return Ok(Some(report)); + }; + self.pending_acknowledgement = Some(PendingAcknowledgement { token, report }); + self.commit_pending_acknowledgement().await.map(Some) } - if let Some(code) = &patch.code { - writes.push(( - EffectTarget::AccountCode { address }, - AbsoluteValue::Bytes(code.clone()), - )); + + async fn commit_pending_acknowledgement( + &mut self, + ) -> Result, ReactiveEngineError> { + let token = self + .pending_acknowledgement + .as_ref() + .expect("caller checked pending acknowledgement") + .token + .clone(); + self.subscriber + .acknowledge_delivery(token) + .await + .map_err(ReactiveEngineError::Acknowledgement)?; + Ok(self + .pending_acknowledgement + .take() + .expect("pending acknowledgement remains until commit") + .report) } - writes -} -fn input_ref(input: &ReactiveInput, ctx: &ReactiveContext) -> InputRef { - match input { - ReactiveInput::Log(log) => InputRef::Log { - chain_id: ctx.chain_id, - block_hash: log - .block_hash - .or(ctx.block.as_ref().map(|block| block.hash)) - .unwrap_or_default(), - transaction_hash: log.transaction_hash.unwrap_or_default(), - log_index: log.log_index.or(ctx.log_index).unwrap_or_default(), - }, - ReactiveInput::PendingTxHash(hash) => InputRef::PendingTx { - chain_id: ctx.chain_id, - hash: *hash, - }, - ReactiveInput::PendingTx(tx) => InputRef::PendingTx { - chain_id: ctx.chain_id, - hash: tx.tx_hash(), - }, - ReactiveInput::BlockHeader(header) => InputRef::Block { - chain_id: ctx.chain_id, - hash: header.hash(), - number: header.number(), - }, - ReactiveInput::FullBlock(block) => { - let header = block.header(); - InputRef::Block { - chain_id: ctx.chain_id, - hash: header.hash(), - number: header.number(), - } + async fn commit_pending_checkpoint( + &mut self, + cache: &EvmCache, + store: &DurableCheckpointStore, + ) -> Result, ReactiveEngineError> { + let pending = self + .pending_checkpoint + .as_mut() + .expect("caller checked pending checkpoint"); + let cache_generation = cache.snapshot_generation(); + if cache_generation != pending.staged_generation { + return Err(ReactiveEngineError::PendingCheckpointCacheChanged { + staged_generation: pending.staged_generation, + current_generation: cache_generation, + }); + } + if pending.saved_to.as_deref() != Some(store.path()) { + store + .save_async(cache, pending.metadata.clone()) + .await + .map_err(ReactiveEngineError::Checkpoint)?; + pending.saved_to = Some(store.path().to_path_buf()); + } + if let Some(token) = pending.delivery_token.clone() { + self.subscriber + .acknowledge_delivery(token) + .await + .map_err(ReactiveEngineError::Acknowledgement)?; + } + + let pending = self + .pending_checkpoint + .take() + .expect("pending checkpoint remains until commit"); + self.last_checkpoint_block = Some(pending.metadata.block); + self.checkpoint_identity = Some(pending.metadata.identity); + self.last_checkpoint_delivery_token = pending + .metadata + .delivery_token + .map(SubscriberDeliveryToken::new); + self.last_checkpoint_delivery_witness = pending.metadata.delivery_witness; + self.last_subscriber_checkpoint = pending + .metadata + .subscriber_checkpoint + .map(SubscriberCheckpoint::new); + Ok(CheckpointedIngest::Applied(pending.report)) + } + + fn ensure_checkpoint_identity( + &self, + cache: &EvmCache, + identity: &DurableCheckpointIdentity, + ) -> Result<(), ReactiveEngineError> { + if identity.chain_id != cache.chain_id() { + return Err(ReactiveEngineError::Checkpoint( + DurableCheckpointError::CacheChainMismatch { + cache_chain_id: cache.chain_id(), + checkpoint_chain_id: identity.chain_id, + }, + )); + } + if let Some(actual) = self.checkpoint_identity.as_ref() + && actual != identity + { + return Err(ReactiveEngineError::Checkpoint( + DurableCheckpointError::IdentityMismatch { + expected: identity.clone(), + actual: actual.clone(), + }, + )); } + if let Some(pending) = self.pending_checkpoint.as_ref() + && &pending.metadata.identity != identity + { + return Err(ReactiveEngineError::Checkpoint( + DurableCheckpointError::IdentityMismatch { + expected: identity.clone(), + actual: pending.metadata.identity.clone(), + }, + )); + } + Ok(()) } } -fn is_canonical_status(status: &ChainStatus) -> bool { - matches!( - status, - ChainStatus::Included { .. } | ChainStatus::Safe { .. } | ChainStatus::Finalized { .. } - ) +fn latest_canonical_batch_block( + batch: &ReactiveInputBatch, +) -> Option { + let record_block = batch + .records() + .iter() + .enumerate() + .filter(|(index, _)| { + batch + .record_delivery_scope(*index) + .is_some_and(DeliveryScope::advances_canonical_state) + }) + .filter_map(|(_, record)| canonical_record_block(record)) + .max_by_key(|block| block.number) + .cloned(); + let control_block = batch + .chain_controls() + .iter() + .filter_map(|control| match control { + ChainControl::Reorg { + common_ancestor, .. + } => Some(common_ancestor), + ChainControl::Barrier { + block: Some(block), .. + } + | ChainControl::CanonicalProgress(block) => Some(block), + ChainControl::Safe(_) + | ChainControl::Finalized(_) + | ChainControl::Barrier { block: None, .. } => None, + }) + .max_by_key(|block| block.number) + .cloned(); + + record_block + .into_iter() + .chain(control_block) + .max_by_key(|block| block.number) + .map(|block| DurableCheckpointBlock { + number: block.number, + hash: block.hash, + parent_hash: block.parent_hash, + timestamp: block.timestamp, + }) } -/// Adapter that wraps a legacy [`EventDecoder`] as a log-only reactive handler. -pub struct EventDecoderHandler { - id: HandlerId, - decoder: Arc, - interest: LogInterest, -} +impl ReactiveEngine +where + N: Network, + S: InterestOwnerSubscriber, +{ + /// Register a handler with both the runtime and subscriber, backfilling its + /// log interests from the runtime's last canonical block. + /// + /// This is the continuity-safe default for mid-lifecycle registration. The + /// subscriber adopts the live desired state first, delivers the new owner's + /// matching records at retained block `C` as owner catch-up, then delivers + /// `C + 1` through activation as global canonical catch-up over the complete + /// handler union. No discovery gap opens, and every effect after `C` enters + /// the ordinary global rollback journal. On a runtime that has not journaled any canonical block yet + /// (fresh start, or `journal_depth` 0) registration is live-only, matching + /// pre-ingestion bootstrap. Use + /// [`register_handler_with_backfill`](Self::register_handler_with_backfill) + /// for an explicit replay of one retained block or + /// [`register_handler_live_only`](Self::register_handler_live_only) to opt + /// out of backfill entirely. + /// + /// Subscriber registration commits before runtime routing is installed. If + /// the subscriber operation fails or is cancelled, the runtime remains + /// unchanged. + /// + /// # Errors + /// + /// Returns [`ReactiveEngineRegisterError`] when the handler id is already + /// registered or the subscriber rejects/does not support the required + /// owner update or coordinated catch-up. + pub async fn register_handler( + &mut self, + handler: Arc>, + ) -> Result<(), ReactiveEngineRegisterError> { + let backfill = self + .runtime + .last_canonical_block() + .filter(|retained| { + self.runtime.journal.iter().any(|entry| { + optional_block_refs_are_compatible(Some(&entry.block), Some(retained)) + }) + }) + .map(HandlerRegistrationCatchup::CoordinatedCanonical) + .unwrap_or(HandlerRegistrationCatchup::LiveOnly); + self.register_handler_inner(handler, backfill).await + } + + /// Register a handler and replay its matching logs at one exact retained + /// canonical block. + /// + /// Owner-only effects are appended to that block's existing rollback + /// journal entry. Consequently this method accepts only a bounded + /// [`SubscriberBackfill`] whose start, end, and hash-certified retained + /// anchor all identify the same journaled block. Wider/deeper recovery must + /// use ordinary global canonical ingestion (for example startup catch-up), + /// where every handler sees the records and the runtime advances coverage. + /// + /// If subscriber registration fails or is cancelled, the runtime remains + /// unchanged. + /// + /// # Errors + /// + /// Returns [`ReactiveEngineRegisterError`] when the handler id is already + /// registered, the requested backfill is not exactly one hash-certified + /// retained journal block, or the subscriber update fails. + pub async fn register_handler_with_backfill( + &mut self, + handler: Arc>, + backfill: SubscriberBackfill, + ) -> Result<(), ReactiveEngineRegisterError> { + self.register_handler_inner(handler, HandlerRegistrationCatchup::OwnerBackfill(backfill)) + .await + } + + /// Register a handler without any log backfill — only logs delivered after + /// its live subscription starts are routed to it. + /// + /// If subscriber registration fails or is cancelled, the runtime remains + /// unchanged. + /// + /// # Errors + /// + /// Returns [`ReactiveEngineRegisterError`] when the handler id is already + /// registered or the subscriber cannot commit the owner update. + pub async fn register_handler_live_only( + &mut self, + handler: Arc>, + ) -> Result<(), ReactiveEngineRegisterError> { + self.register_handler_inner(handler, HandlerRegistrationCatchup::LiveOnly) + .await + } + + async fn register_handler_inner( + &mut self, + handler: Arc>, + catchup: HandlerRegistrationCatchup, + ) -> Result<(), ReactiveEngineRegisterError> { + let id = handler.id(); + if self.runtime.contains_handler(&id) { + return Err(RegisterError::DuplicateHandler(id).into()); + } + let interests = handler.interests(); + + if let HandlerRegistrationCatchup::OwnerBackfill(backfill) = &catchup { + let retained_anchor = backfill.retained_anchor().copied(); + let is_exact_retained_block = retained_anchor.is_some_and(|anchor| { + backfill.start_block() == anchor.number + && backfill.end_block() == Some(anchor.number) + && self.runtime.journal.iter().any(|entry| { + optional_block_refs_are_compatible(Some(&entry.block), Some(&anchor)) + }) + }); + if !is_exact_retained_block { + return Err(ReactiveEngineRegisterError::BackfillOutsideJournal { + start_block: backfill.start_block(), + end_block: backfill.end_block(), + retained_anchor, + }); + } + } -impl EventDecoderHandler { - /// Create an adapter from a decoder and log interest. - pub fn new(id: HandlerId, decoder: Arc, interest: LogInterest) -> Self { - Self { - id, - decoder, - interest, + let subscribed = match catchup { + HandlerRegistrationCatchup::OwnerBackfill(backfill) => { + self.subscriber + .add_interest_owner_with_backfill(id.clone(), &interests, backfill) + .await + } + HandlerRegistrationCatchup::CoordinatedCanonical(retained) => { + self.subscriber + .add_interest_owner_with_canonical_catchup(id.clone(), &interests, retained) + .await + } + HandlerRegistrationCatchup::LiveOnly => { + self.subscriber + .add_interest_owner(id.clone(), &interests) + .await + } + }; + if let Err(error) = subscribed { + return Err(error.into()); } - } -} -impl ReactiveHandler for EventDecoderHandler { - fn id(&self) -> HandlerId { - self.id.clone() + // `&mut self` excludes concurrent registry mutation between the + // duplicate preflight and this commit. Registration is deliberately + // subscriber-first: cancelling the awaited operation cannot leave a + // runtime handler active without committed subscriber interests. + self.runtime + .registry + .insert_handler_prepared(id, handler, interests); + Ok(()) } - fn interests(&self) -> Vec> { - vec![ReactiveInterest::Logs(self.interest.clone())] + /// Register every handler currently in the runtime registry as a subscriber + /// interest owner. + /// + /// This is the no-history bootstrap path for a fresh runtime/subscriber pair + /// before ingestion starts, or for reattaching an already-aligned durable + /// subscriber whose exact owner state was restored independently. Each + /// handler becomes its own owner through one exact bulk replacement; + /// crash-stale owners and unowned/base interests are removed. + /// + /// No backfill is requested. It is therefore **not** the restart-recovery path for a new or + /// potentially stale subscriber after the runtime has processed canonical + /// state: use + /// [`sync_handler_interests_with_backfill`](Self::sync_handler_interests_with_backfill), + /// which exact-replaces the owner set and closes continuity from the + /// restored runtime position. + /// + /// The complete exact set commits through one subscriber operation; an + /// error or cancellation leaves the previously committed topology + /// authoritative. + /// + /// # Errors + /// + /// Returns [`SubscriberError`] when the subscriber cannot atomically + /// replace the complete owner topology. + pub async fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> { + let owners = self + .runtime + .handler_ids() + .into_iter() + .map(|id| { + let interests = self + .runtime + .handler_interests(&id) + .map(<[ReactiveInterest]>::to_vec) + .unwrap_or_default(); + (id, interests) + }) + .collect(); + self.subscriber.replace_interest_owners(owners).await } - fn handle( - &self, - _ctx: &ReactiveContext, - input: &ReactiveInput, - state: &dyn StateView, - ) -> Result { - let ReactiveInput::Log(log) = input else { - return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect)); - }; - - Ok(HandlerOutcome { - effects: self - .decoder - .decode(&log.inner, state) - .into_iter() - .map(ReactiveEffect::StateUpdate) - .collect(), - quality: StateEffectQuality::ExactFromInput, - tags: Vec::new(), - }) + /// Rebuild subscriber owner state from a runtime that already embodies a + /// canonical checkpoint. + /// + /// The runtime registry is authoritative: the subscriber must atomically + /// replace its complete owner set, removing crash-stale owners as well as + /// adding the current ones. Log catch-up is routed globally through normal + /// canonical ingestion and begins strictly at `C + 1`, where + /// `C` is [`ReactiveRuntime::last_canonical_block`], because the restored + /// cache already contains every effect through `C`. The exact number/hash + /// identity of `C` remains attached as a retained baseline and must be + /// validated by the subscriber before it exposes post-baseline records. + /// Global routing is essential: startup catch-up effects enter the ordinary + /// canonical journal and can be rolled back if the certified branch later + /// reorganizes; owner-only catch-up is reserved for a true mid-lifecycle + /// handler addition. + /// + /// A runtime without a canonical position must use + /// [`sync_handler_interests`](Self::sync_handler_interests) instead. Block + /// `u64::MAX` is rejected rather than wrapping or replaying the baseline. + /// The replacement is one subscriber commit boundary: errors and + /// cancellation leave the previous topology authoritative. + /// + /// # Errors + /// + /// Returns [`SubscriberError::InvalidConfig`] when no canonical baseline + /// exists or no exclusive successor can be represented, and otherwise + /// propagates subscriber validation, transport, or atomic-commit failures. + pub async fn sync_handler_interests_with_backfill(&mut self) -> Result<(), SubscriberError> { + let baseline = + self.runtime + .last_canonical_block() + .ok_or(SubscriberError::InvalidConfig( + "cannot continuity-sync handlers before a canonical runtime position exists", + ))?; + let backfill = SubscriberBackfill::after_canonical_block(baseline)?; + let owners = self + .runtime + .handler_ids() + .into_iter() + .map(|id| { + let interests = self + .runtime + .handler_interests(&id) + .map(<[ReactiveInterest]>::to_vec) + .unwrap_or_default(); + (id, interests) + }) + .collect(); + self.subscriber + .replace_interest_owners_with_global_backfill(owners, backfill) + .await } -} -/// Provider-agnostic subscriber interface. -pub trait EventSubscriber: Send { - /// Replace all interests registered with the subscriber. + /// Unregister a handler from both the subscriber and runtime. /// - /// Implementations may use this as a full setup/reset operation. The - /// in-crate [`AlloySubscriber`] clears owner-scoped interest state and - /// delivery/dedupe bookkeeping when this method is called. - fn register_interests( + /// Subscriber interests are removed first so no new live records are routed + /// to a handler after it has left the runtime registry. Returns the removed + /// handler when the id was registered. If subscriber removal fails or is + /// cancelled, runtime routing remains installed. + /// + /// This is the routing/transport half of dropping an adapter. State the + /// handler accumulated is deliberately left in place; the complete teardown + /// for a pool or adapter that will not return is: + /// + /// ```text + /// engine.unregister_handler(&id).await?; + /// for request_id in handler_request_ids { + /// // Drop only this handler generation's queued repair work. + /// engine.runtime_mut().cancel_pending_resync(&request_id); + /// } + /// for address in exclusively_owned_addresses { + /// // Shared accounts require caller-side owner reference counting. + /// engine.runtime_mut().untrack_account(address); + /// } + /// // optional: evict cached state via StateUpdate::purge / cache purge APIs + /// ``` + /// + /// Health, metrics, the reorg journal, hooks, and freshness stamps are + /// runtime-global and are never touched by handler removal. + /// + /// # Errors + /// + /// Returns [`SubscriberError`] when the subscriber cannot commit owner + /// removal. In that case runtime routing remains installed. + pub async fn unregister_handler( &mut self, - interests: &[ReactiveInterest], - ) -> Result<(), SubscriberError>; + id: &HandlerId, + ) -> Result>>, SubscriberError> { + self.subscriber.remove_interest_owner(id).await?; + Ok(self.runtime.unregister_handler(id)) + } +} - /// Return the next input batch, or `Ok(None)` when the stream is exhausted. - fn next_batch(&mut self) -> SubscriberNextBatch<'_, N>; +/// Alloy-backed event subscriber. +/// +/// The default transport slice drives Alloy pubsub subscriptions for logs, +/// block headers, and pending transaction hashes. The HTTP polling `watch_*` +/// transport remains available behind the opt-in `reactive-polling` feature. +/// Pubsub streams reconnect automatically after termination, and log +/// subscriptions are backfilled from the last seen block. Owner-scoped log +/// additions can request backfill from an explicit block anchor. Full pending +/// transaction hydration and full block bodies remain explicit follow-up work. +/// +/// Historical log fetching is deliberately a bounded live-subscriber aid, not +/// a high-volume indexer: each filter/window is issued as one complete-range +/// `eth_getLogs` request. [`SubscriberConfig::max_backfill_log_bytes`] rejects +/// an oversized decoded response, but the subscriber does not adaptively split +/// block ranges and cannot bypass an RPC provider's result cap. Keep owner +/// registration and reconnect windows modest; use an indexing source such as +/// HyperSync behind [`EventSubscriber`] for deep or high-density catch-up. +/// +/// With no registered interests, [`EventSubscriber::next_batch`] returns +/// `Ok(None)`. +pub struct AlloySubscriber { + provider: P, + /// Stable identity for the provider session used by Flashblocks and every + /// follow-up pending-state read. + provider_ref: Option, + /// Optional provider dedicated to canonical log-context verification. + /// Keeping this separate prevents a high-volume pubsub connection from + /// starving its own verification requests behind log notifications. + log_verification_provider: Option

, + /// Provider chain identity, resolved once before any record can escape. + chain_id: Option, + mode: SubscriberMode, + config: SubscriberConfig, + base_interests: Vec>, + owned_interests: Vec>, + next_owner_epoch: u64, + interests: Vec>, + /// Stable source id per distinct provider-facing log filter. Ids key + /// delivery anchors and live `SubscriberEvent`s; entries are retired (and + /// their anchors pruned) when no planned stream references the filter, so + /// long-lived owner churn cannot grow this map unboundedly. + log_source_ids: HashMap, + next_log_source_id: usize, + pending_backfills: VecDeque, + /// Successfully connected sources whose subscribe-then-backfill step has + /// not committed yet. Installation happens before the backfill await, so a + /// cancelled reconcile keeps the live stream and retries only the missing + /// historical window. + pending_source_backfills: VecDeque, + /// Set when interest bookkeeping changed since the last successful stream + /// reconcile, so steady-state polling skips the desired-vs-live diff. + sources_dirty: bool, + /// Conservative generation of desired/live stream topology. Successful + /// owner progress is activatable only against the same clean revision. + stream_revision: u64, + state: AlloySubscriberState, + pending_records: VecDeque>, + pending_chain_controls: VecDeque, + /// Owner copies of live records consumed during an in-flight reconcile. + /// These remain hidden from subscriber output until the owning reconcile + /// commits and survive cancellation so subscribe-first adoption cannot + /// lose an event at an await boundary. + pending_reconcile_owner_records: VecDeque>, + /// Sticky fail-closed capacity error. Once an event could not be retained, + /// only a full replacement registration can establish a new baseline. + resource_error: Option, + last_seen_log_blocks: HashMap, + verified_log_blocks: HashMap<(u64, B256), BlockRef>, + verified_log_block_order: VecDeque<(u64, B256)>, + recent_input_refs: VecDeque, + recent_input_ref_set: HashSet, + recent_owner_input_refs: HashMap>, + recent_owner_input_ref_sets: HashMap>, + recent_compat_owner_input_refs: HashMap>, + recent_compat_owner_input_ref_sets: HashMap>, + base_flashblock_header: Option<(FixedBytes<8>, BaseFlashblockBase)>, + flashblocks_by_hash: HashMap, + flashblock_hash_order: VecDeque, + unmatched_pending_logs: VecDeque<(usize, Log)>, + latest_preconfirmation: Option, + preconfirmed_seen_logs: HashSet<(B256, u64)>, + _network: PhantomData, } -/// Boxed future returned by [`EventSubscriber::next_batch`]. -pub type SubscriberNextBatch<'a, N> = Pin< - Box>, SubscriberError>> + Send + 'a>, ->; +struct OwnedSubscriberInterests { + owner: HandlerId, + interests: Vec>, + epoch: Option, + state: SubscriberOwnerState, + baseline: Option, + progress: Option, + progress_stream_revision: Option, +} -/// Boxed future returned by [`AlloySubscriber::next_scoped_batch`]. -pub type SubscriberNextScopedBatch<'a, N> = Pin< - Box>, SubscriberError>> + Send + 'a>, ->; +#[derive(Clone)] +struct SubscriberOwnerReconcilePlan { + epoch: SubscriberOwnerEpoch, + interests: Vec>, + retained: BlockRef, + from_block: u64, +} -/// Subscriber mode requested for the Alloy subscriber. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub enum SubscriberMode { - /// Prefer the default compiled transport. - /// - /// With the default `reactive-ws` feature this resolves to pubsub/WebSocket - /// subscriptions. Without `reactive-ws`, it resolves to polling only when - /// the opt-in `reactive-polling` feature is enabled. - #[default] - Auto, - /// Use provider pubsub streams. - PubSub, - /// Use polling/watch APIs. Requires the `reactive-polling` feature. - Polling, +struct SubscriberOwnerCatchup { + logs: Vec, + certified: BlockRef, } -/// Subscriber configuration. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SubscriberConfig { - /// Hydrate pending transaction hashes into full bodies when possible. - pub hydrate_pending_transactions: bool, - /// Maximum records to emit per batch. - pub max_batch_size: usize, - /// Maximum distinct contract addresses placed in one provider-side log - /// subscription. Compatible logical owner filters are fanned into address - /// supersets up to this limit; exact owner routing still happens locally. - pub max_log_addresses_per_subscription: usize, - /// Reconnect policy for WebSocket/pubsub streams. - pub reconnect: SubscriberReconnectConfig, +#[derive(Clone, Copy)] +struct SubscriberOwnerCatchupOptions { + target_preverified: bool, + max_logs: usize, + max_log_bytes: usize, + max_requests_in_flight: usize, } -impl Default for SubscriberConfig { - fn default() -> Self { - Self { - hydrate_pending_transactions: false, - max_batch_size: 1024, - max_log_addresses_per_subscription: 1024, - reconnect: SubscriberReconnectConfig::default(), - } - } +struct SubscriberOwnerReconcileFilter { + filter: Filter, + from_block: u64, } -/// WebSocket/pubsub reconnect policy. -/// -/// Reconnects are applied after an established subscription stream terminates. -/// Initial subscription failures are still returned immediately so deployment -/// mistakes, unsupported transports, and bad endpoints fail fast. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SubscriberReconnectConfig { - /// Whether pubsub streams should be recreated after termination. - pub enabled: bool, - /// Delay before the first reconnect attempt. - pub initial_delay: Duration, - /// Delay before the second reconnect attempt. Later retries double this - /// delay up to [`Self::max_delay`]. - pub retry_delay: Duration, - /// Maximum delay between reconnect attempts. - pub max_delay: Duration, - /// Maximum reconnect attempts per terminated stream. `None` retries forever. - pub max_attempts: Option, - /// Number of recently emitted canonical input refs remembered to suppress - /// duplicates across reconnect backfill and subscription replay. - pub dedupe_window: usize, +struct BufferedSubscriberOwnerRecord { + record: ReactiveInputRecord, + owners: Vec, } -impl Default for SubscriberReconnectConfig { - fn default() -> Self { - Self { - enabled: true, - initial_delay: Duration::ZERO, - retry_delay: Duration::from_millis(250), - max_delay: Duration::from_secs(30), - max_attempts: Some(3), - dedupe_window: 4096, - } - } +const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256; + +struct QueuedSubscriberBackfill { + /// `None` means global canonical catch-up; `Some` is compatibility + /// owner-only catch-up for true mid-lifecycle additions. + owner: Option, + epoch: Option, + /// Complete logical filter set for one certified, globally ordered window. + filters: Vec, + backfill: SubscriberBackfill, } -/// Historical log backfill requested when adding subscriber interests. -/// -/// Backfill applies only to [`ReactiveInterest::Logs`] entries. Block and -/// pending-transaction interests are live-only. `AlloySubscriber` emits records -/// fetched through this policy as [`InputSource::Backfill`] before attempting -/// live stream initialization, and a drained backfill seeds the filter's -/// delivery anchor at its resolved upper bound (even when the window held no -/// logs), so the newly added filter gets the same reconnect/catch-up protection -/// an established one has. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SubscriberBackfill { - from_block: u64, - to_block: Option, +/// Best-effort installation of rustls' `ring` crypto provider as the process +/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with +/// "no process-level CryptoProvider available". Runs at most once and ignores the +/// error if a default provider is already installed (the host app may have set +/// its own). +#[cfg(feature = "reactive-ws")] +fn ensure_ring_crypto_provider() { + use std::sync::Once; + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); } -impl SubscriberBackfill { - /// Backfill an inclusive block range. - pub fn range(from_block: u64, to_block: u64) -> Self { +impl AlloySubscriber { + /// Create a new Alloy subscriber. + pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self { + #[cfg(feature = "reactive-ws")] + ensure_ring_crypto_provider(); Self { - from_block, - to_block: Some(to_block), + provider, + provider_ref: None, + log_verification_provider: None, + chain_id: None, + mode, + config, + base_interests: Vec::new(), + owned_interests: Vec::new(), + next_owner_epoch: 0, + interests: Vec::new(), + log_source_ids: HashMap::new(), + next_log_source_id: 0, + pending_backfills: VecDeque::new(), + pending_source_backfills: VecDeque::new(), + sources_dirty: true, + stream_revision: 0, + state: AlloySubscriberState::Uninitialized, + pending_records: VecDeque::new(), + pending_chain_controls: VecDeque::new(), + pending_reconcile_owner_records: VecDeque::new(), + resource_error: None, + last_seen_log_blocks: HashMap::new(), + verified_log_blocks: HashMap::new(), + verified_log_block_order: VecDeque::new(), + recent_input_refs: VecDeque::new(), + recent_input_ref_set: HashSet::new(), + recent_owner_input_refs: HashMap::new(), + recent_owner_input_ref_sets: HashMap::new(), + recent_compat_owner_input_refs: HashMap::new(), + recent_compat_owner_input_ref_sets: HashMap::new(), + base_flashblock_header: None, + flashblocks_by_hash: HashMap::new(), + flashblock_hash_order: VecDeque::new(), + unmatched_pending_logs: VecDeque::new(), + latest_preconfirmation: None, + preconfirmed_seen_logs: HashSet::new(), + _network: PhantomData, } } - /// Backfill from `from_block` through the provider's latest block. - pub fn from_block(from_block: u64) -> Self { - Self { - from_block, - to_block: None, + /// Borrow the provider. + pub fn provider(&self) -> &P { + &self.provider + } + + /// Bind this subscriber to the concrete provider lease that supplies + /// Flashblocks. Callers obtain the lease from a transport endpoint marked + /// with the single `flashblocks = true` flag. + #[must_use] + pub fn with_provider_ref(mut self, provider: ProviderRef) -> Self { + self.provider_ref = Some(provider); + self + } + + /// Use a separate provider for canonical log-context verification. + /// + /// This is recommended with + /// [`SubscriberConfig::verify_log_block_context`] in high-volume pubsub + /// deployments. The provider must target the same chain; every fetched + /// block is still checked against the log's number, hash, and timestamp. + #[must_use] + pub fn with_log_verification_provider(mut self, provider: P) -> Self { + self.log_verification_provider = Some(provider); + self + } + + /// Subscriber mode. + pub fn mode(&self) -> SubscriberMode { + self.mode + } + + /// Subscriber config. + pub fn config(&self) -> &SubscriberConfig { + &self.config + } + + /// Registered interests across base and owner-scoped registrations. + pub fn registered_interests(&self) -> &[ReactiveInterest] { + &self.interests + } + + /// Stage a fresh, epoch-scoped interest owner without making its inputs + /// canonically routable yet. + /// + /// The returned token is required by every later lifecycle operation. A + /// staged owner participates in provider subscription planning immediately, + /// while its matching input remains owner-scoped until + /// [`activate_interest_owner`](Self::activate_interest_owner) succeeds. + /// Post-block owners require hash-certified + /// [`reconcile_interest_owner`](Self::reconcile_interest_owner) progress on + /// the current clean stream revision before activation. + /// + /// # Errors + /// + /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration, + /// duplicate owners, unsupported post-block interests, unsupported + /// transport interests, block-number overflow, or epoch exhaustion. + pub fn stage_interest_owner( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + start: SubscriberOwnerStart, + ) -> Result { + validate_subscriber_config(&self.config)?; + if matches!(&start, SubscriberOwnerStart::PostBlock(_)) + && interests + .iter() + .any(|interest| !matches!(interest, ReactiveInterest::Logs(_))) + { + return Err(SubscriberOwnerError::UnsupportedPostBlockInterest); + } + if self + .owned_interests + .iter() + .any(|entry| entry.owner == owner) + { + return Err(SubscriberOwnerError::AlreadyRegistered(owner)); } + + let mut next_owned = self.clone_owned_interests(); + next_owned.push(OwnedSubscriberInterests { + owner: owner.clone(), + interests: interests.to_vec(), + epoch: None, + state: SubscriberOwnerState::Staged, + baseline: None, + progress: None, + progress_stream_revision: None, + }); + let next_registered = aggregate_interests(&self.base_interests, &next_owned); + validate_supported_interests(self.mode, &self.config, &next_registered)?; + + let baseline = match start { + SubscriberOwnerStart::Live => None, + SubscriberOwnerStart::PostBlock(block) => { + block + .number + .checked_add(1) + .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?; + Some(block) + } + }; + let sequence = self + .next_owner_epoch + .checked_add(1) + .ok_or(SubscriberOwnerError::EpochExhausted)?; + let epoch = SubscriberOwnerEpoch { + owner: owner.clone(), + sequence, + }; + + self.next_owner_epoch = sequence; + let entry = next_owned + .last_mut() + .expect("staged owner was appended during preflight"); + entry.epoch = Some(epoch.clone()); + entry.baseline = baseline; + self.owned_interests = next_owned; + self.interests = next_registered; + self.sources_dirty = true; + + Ok(epoch) } - /// First block included in the backfill. - pub fn start_block(&self) -> u64 { - self.from_block + /// Stage replacement interests for one currently active logical owner. + /// + /// The active epoch remains canonical while the replacement reconciles. + /// Commit both epochs atomically with + /// [`commit_interest_owner_replacement`](Self::commit_interest_owner_replacement), + /// or abort the staged epoch with [`abort_interest_owner`](Self::abort_interest_owner). + /// + /// # Errors + /// + /// Returns [`SubscriberOwnerError`] for invalid subscriber configuration, + /// missing/non-unique active owner state, unsupported post-block interests, + /// unsupported transport interests, block-number overflow, or epoch + /// exhaustion. + pub fn stage_interest_owner_replacement( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + start: SubscriberOwnerStart, + ) -> Result { + validate_subscriber_config(&self.config)?; + if matches!(&start, SubscriberOwnerStart::PostBlock(_)) + && interests + .iter() + .any(|interest| !matches!(interest, ReactiveInterest::Logs(_))) + { + return Err(SubscriberOwnerError::UnsupportedPostBlockInterest); + } + let active_count = self + .owned_interests + .iter() + .filter(|entry| { + entry.owner == owner + && entry.state == SubscriberOwnerState::Active + && entry.epoch.is_some() + }) + .count(); + if active_count != 1 + || self + .owned_interests + .iter() + .any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active) + { + return Err(SubscriberOwnerError::AlreadyRegistered(owner)); + } + + let mut next_owned = self.clone_owned_interests(); + next_owned.push(OwnedSubscriberInterests { + owner: owner.clone(), + interests: interests.to_vec(), + epoch: None, + state: SubscriberOwnerState::Staged, + baseline: None, + progress: None, + progress_stream_revision: None, + }); + let next_registered = aggregate_interests(&self.base_interests, &next_owned); + validate_supported_interests(self.mode, &self.config, &next_registered)?; + + let baseline = match start { + SubscriberOwnerStart::Live => None, + SubscriberOwnerStart::PostBlock(block) => { + block + .number + .checked_add(1) + .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?; + Some(block) + } + }; + let sequence = self + .next_owner_epoch + .checked_add(1) + .ok_or(SubscriberOwnerError::EpochExhausted)?; + let epoch = SubscriberOwnerEpoch { + owner: owner.clone(), + sequence, + }; + + self.next_owner_epoch = sequence; + let entry = next_owned + .last_mut() + .expect("staged replacement owner was appended during preflight"); + entry.epoch = Some(epoch.clone()); + entry.baseline = baseline; + self.owned_interests = next_owned; + self.interests = next_registered; + self.sources_dirty = true; + Ok(epoch) } - /// Last block included in the backfill, or `None` for provider latest. - pub fn end_block(&self) -> Option { - self.to_block + /// Current transaction state for an exact owner epoch. + pub fn interest_owner_state( + &self, + epoch: &SubscriberOwnerEpoch, + ) -> Option { + self.owned_interests + .iter() + .find(|entry| entry.epoch.as_ref() == Some(epoch)) + .map(|entry| entry.state) } -} - -/// Opaque generation for one transaction-aware subscriber interest owner. -/// -/// Epochs are allocated monotonically by [`AlloySubscriber`] and are never -/// reused, including after an aborted stage or a full interest replacement. -/// Lifecycle operations require the complete token so a delayed command for an -/// older registration cannot affect a replacement using the same [`HandlerId`]. -#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct SubscriberOwnerEpoch { - owner: HandlerId, - sequence: u64, -} - -/// Delivery audience retained with a subscriber input record. -/// -/// Canonical inputs are forwarded once to the runtime actor and may also name -/// staged epochs that need a buffered copy. Owner-only inputs are catch-up or -/// overlap records that must never be routed through existing canonical -/// handlers. -#[derive(Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum SubscriberInputScope { - /// One canonical input plus any staged owners that matched at enqueue time. - Canonical { - /// Staged owner epochs that require a buffered copy. - owners: Vec, - }, - /// Input delivered only to the listed staged owners. - OwnerOnly { - /// Exact staged owner epochs receiving the input. - owners: Vec, - }, -} -impl SubscriberInputScope { - /// Exact staged owner epochs attached to this input. - pub fn owners(&self) -> &[SubscriberOwnerEpoch] { - match self { - Self::Canonical { owners } | Self::OwnerOnly { owners } => owners, - } + /// Latest hash-certified reconcile progress for an exact owner epoch. + pub fn interest_owner_progress( + &self, + epoch: &SubscriberOwnerEpoch, + ) -> Option<&SubscriberOwnerProgress> { + self.owned_interests + .iter() + .find(|entry| entry.epoch.as_ref() == Some(epoch)) + .and_then(|entry| entry.progress.as_ref()) } - /// Whether this input must be forwarded once through canonical routing. - pub const fn is_canonical(&self) -> bool { - matches!(self, Self::Canonical { .. }) + /// Make a staged owner canonical after its actor-side installation commits. + /// + /// Returns `false` for stale tokens and owners not currently staged. + pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool { + let stream_revision = self.stream_revision; + let sources_dirty = self.sources_dirty; + let Some(entry) = self + .owned_interests + .iter_mut() + .find(|entry| entry.epoch.as_ref() == Some(epoch)) + else { + return false; + }; + if entry.state != SubscriberOwnerState::Staged + || (entry.baseline.is_some() + && (entry.progress.is_none() + || entry.progress_stream_revision != Some(stream_revision) + || sources_dirty)) + { + return false; + } + entry.state = SubscriberOwnerState::Active; + true } -} -/// Reactive input together with its canonical/owner-scoped delivery audience. -#[derive(Clone, Debug)] -pub struct SubscriberInputRecord { - record: ReactiveInputRecord, - scope: SubscriberInputScope, -} + /// Atomically replace one active owner epoch with one reconciled staged epoch. + pub fn commit_interest_owner_replacement( + &mut self, + active: &SubscriberOwnerEpoch, + replacement: &SubscriberOwnerEpoch, + ) -> bool { + let Some(active_index) = self + .owned_interests + .iter() + .position(|entry| entry.epoch.as_ref() == Some(active)) + else { + return false; + }; + let Some(replacement_index) = self + .owned_interests + .iter() + .position(|entry| entry.epoch.as_ref() == Some(replacement)) + else { + return false; + }; + if active_index == replacement_index + || active.owner() != replacement.owner() + || self.owned_interests[active_index].state != SubscriberOwnerState::Active + || self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged + || (self.owned_interests[replacement_index].baseline.is_some() + && (self.owned_interests[replacement_index].progress.is_none() + || self.owned_interests[replacement_index].progress_stream_revision + != Some(self.stream_revision) + || self.sources_dirty)) + { + return false; + } -impl SubscriberInputRecord { - /// Borrow the reactive input record. - pub const fn record(&self) -> &ReactiveInputRecord { - &self.record + self.owned_interests[replacement_index].state = SubscriberOwnerState::Active; + self.owned_interests.remove(active_index); + self.purge_owner_epoch(active); + self.rebuild_registered_interests(); + self.retire_unreferenced_filters(); + self.sources_dirty = true; + true } - /// Delivery audience captured when the record was enqueued. - pub const fn scope(&self) -> &SubscriberInputScope { - &self.scope + /// Prepare an exact active owner for removal without changing desired + /// interests, streams, anchors, or queued canonical input. + /// + /// The caller establishes its delivery fence after this transition. Use + /// [`abort_interest_owner`](Self::abort_interest_owner) to restore the owner + /// on actor-side failure, or + /// [`finalize_interest_owner_removal`](Self::finalize_interest_owner_removal) + /// once canonical routing has been removed. + pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool { + let Some(entry) = self + .owned_interests + .iter_mut() + .find(|entry| entry.epoch.as_ref() == Some(epoch)) + else { + return false; + }; + if entry.state != SubscriberOwnerState::Active { + return false; + } + entry.state = SubscriberOwnerState::Removing; + true } - /// Consume the scoped value into its reactive input record. - pub fn into_record(self) -> ReactiveInputRecord { - self.record + /// Finalize a previously prepared exact owner removal. + /// + /// Returns the removed interests, or `None` for stale tokens and owners not + /// currently in [`SubscriberOwnerState::Removing`]. Repeating finalization + /// is therefore idempotent. + pub fn finalize_interest_owner_removal( + &mut self, + epoch: &SubscriberOwnerEpoch, + ) -> Option>> { + let index = self.owned_interests.iter().position(|entry| { + entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing + })?; + let removed = self.owned_interests.remove(index).interests; + self.purge_owner_epoch(epoch); + self.rebuild_registered_interests(); + self.retire_unreferenced_filters(); + self.sources_dirty = true; + Some(removed) } -} - -impl std::ops::Deref for SubscriberInputRecord { - type Target = ReactiveInputRecord; - fn deref(&self) -> &Self::Target { - &self.record + /// Abort an epoch-scoped owner lifecycle operation. + /// + /// A staged owner is removed completely. A prepared removal is restored to + /// active. Active and unknown epochs are unchanged. Repeating the same + /// abort is therefore safe and returns `false` after the first effect. + pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool { + let Some(index) = self + .owned_interests + .iter() + .position(|entry| entry.epoch.as_ref() == Some(epoch)) + else { + return false; + }; + match self.owned_interests[index].state { + SubscriberOwnerState::Staged => { + self.owned_interests.remove(index); + self.purge_owner_epoch(epoch); + self.rebuild_registered_interests(); + self.retire_unreferenced_filters(); + self.sources_dirty = true; + true + } + SubscriberOwnerState::Removing => { + self.owned_interests[index].state = SubscriberOwnerState::Active; + true + } + SubscriberOwnerState::Active => false, + } } -} - -/// Batch of subscriber inputs with enqueue-time owner provenance. -#[derive(Clone, Debug)] -pub struct SubscriberInputBatch { - records: Vec>, -} -/// Result of polling a scoped subscriber batch against one driver control -/// future. -#[derive(Debug)] -#[non_exhaustive] -pub enum SubscriberDriverPoll { - /// The control future completed first; subscriber delivery remains intact. - Control(C), - /// Subscriber polling completed first. - Batch(Option>), -} + fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) { + self.pending_backfills + .retain(|backfill| backfill.epoch.as_ref() != Some(epoch)); + self.pending_records + .retain_mut(|pending| match &mut pending.scope { + SubscriberInputScope::Canonical { owners } + | SubscriberInputScope::CanonicalResidual { owners, .. } => { + owners.retain(|owner| owner != epoch); + true + } + SubscriberInputScope::OwnerOnly { owners } => { + owners.retain(|owner| owner != epoch); + !owners.is_empty() + } + SubscriberInputScope::OwnerOnlyHandlers { .. } + | SubscriberInputScope::Preconfirmed => true, + }); + self.pending_reconcile_owner_records.retain_mut(|pending| { + pending.owners.retain(|owner| owner != epoch); + !pending.owners.is_empty() + }); + self.recent_owner_input_refs.remove(epoch); + self.recent_owner_input_ref_sets.remove(epoch); + } -impl SubscriberInputBatch { - /// Borrow every scoped record in delivery order. - pub fn records(&self) -> &[SubscriberInputRecord] { - &self.records + /// Atomically add or replace several owners while preserving unrelated ones. + /// + /// # Errors + /// + /// Returns [`SubscriberError`] for invalid configuration, duplicate owners, + /// mixed lifecycle APIs, unsupported interests, or backfill-capacity + /// exhaustion. No owner state changes on error. + pub fn upsert_interest_owners( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + ) -> Result<(), SubscriberError> { + self.upsert_interest_owners_inner(owners, None) } - /// Consume the batch into its scoped records. - pub fn into_records(self) -> Vec> { - self.records + /// Atomically add or replace several owners and queue one common backfill + /// policy for every log interest while preserving unrelated owners. + /// + /// # Errors + /// + /// Returns [`SubscriberError`] for invalid configuration, duplicate owners, + /// mixed lifecycle APIs, unsupported interests, or backfill-capacity + /// exhaustion. No owner or backfill state changes on error. + pub fn upsert_interest_owners_with_backfill( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + backfill: SubscriberBackfill, + ) -> Result<(), SubscriberError> { + self.upsert_interest_owners_inner(owners, Some(backfill)) } - fn into_reactive_batch(self) -> ReactiveInputBatch { - ReactiveInputBatch::new( - self.records + fn upsert_interest_owners_inner( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + explicit_backfill: Option, + ) -> Result<(), SubscriberError> { + validate_subscriber_config(&self.config)?; + let mut seen = HashSet::with_capacity(owners.len()); + let mut next_owned = self.clone_owned_interests(); + for (owner, interests) in &owners { + if !seen.insert(owner.clone()) { + return Err(SubscriberError::InvalidConfig( + "bulk owner upsert contains a duplicate owner", + )); + } + if self + .owned_interests + .iter() + .any(|entry| &entry.owner == owner && entry.epoch.is_some()) + { + return Err(SubscriberError::InvalidConfig( + "cannot mix compatibility and epoch-scoped owner lifecycle APIs", + )); + } + if let Some(entry) = next_owned.iter_mut().find(|entry| &entry.owner == owner) { + entry.interests = interests.clone(); + entry.state = SubscriberOwnerState::Active; + entry.baseline = None; + entry.progress = None; + entry.progress_stream_revision = None; + } else { + next_owned.push(OwnedSubscriberInterests { + owner: owner.clone(), + interests: interests.clone(), + epoch: None, + state: SubscriberOwnerState::Active, + baseline: None, + progress: None, + progress_stream_revision: None, + }); + } + } + let next_registered = aggregate_interests(&self.base_interests, &next_owned); + validate_supported_interests(self.mode, &self.config, &next_registered)?; + + // Build every owner's replacement queue before the first mutation. + // Besides keeping capacity failure atomic, this preserves continuity + // for changed filter shapes when the caller did not provide a common + // open-ended backfill that already covers the old delivery anchor. + let mut replacement_backfills = Vec::new(); + for (owner, interests) in &owners { + let previous_filters: Vec = self + .owner_interests(owner) + .map(log_filters) + .unwrap_or_default(); + let continuity_anchor = previous_filters + .iter() + .filter_map(|filter| self.log_anchor(filter)) + .min(); + let filters = log_filters(interests); + if let Some(backfill) = explicit_backfill + && !filters.is_empty() + { + replacement_backfills.push(QueuedSubscriberBackfill { + owner: Some(owner.clone()), + epoch: None, + filters: filters.clone(), + backfill, + }); + } + let explicit_covers = explicit_backfill.is_some_and(|explicit| { + explicit.end_block().is_none() + && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor) + }); + let continuity_filters: Vec<_> = filters .into_iter() - .map(SubscriberInputRecord::into_record) - .collect(), - ) - } -} + .filter(|filter| !previous_filters.contains(filter)) + .collect(); + if let Some(anchor) = continuity_anchor + && !continuity_filters.is_empty() + && !explicit_covers + { + replacement_backfills.push(QueuedSubscriberBackfill { + owner: Some(owner.clone()), + epoch: None, + filters: continuity_filters, + backfill: SubscriberBackfill::from_block(anchor), + }); + } + } -impl SubscriberOwnerEpoch { - /// Logical subscriber owner represented by this epoch. - pub const fn owner(&self) -> &HandlerId { - &self.owner - } + let retained_backfills = self + .pending_backfills + .iter() + .filter(|queued| { + queued + .owner + .as_ref() + .is_none_or(|owner| !seen.contains(owner)) + }) + .map(|queued| queued.filters.len()) + .sum::(); + let replacement_units = replacement_backfills + .iter() + .map(|queued| queued.filters.len()) + .sum::(); + if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills + { + return Err(SubscriberError::ResourceExhausted(format!( + "bulk owner update would queue more than {} lazy backfills", + self.config.max_pending_backfills + ))); + } - /// Monotonic subscriber-local epoch sequence. - pub const fn sequence(&self) -> u64 { - self.sequence + // All validation and capacity checks are complete. The remaining + // assignments have no failure or cancellation point, so topology and + // historical work become authoritative as one local commit. + self.owned_interests = next_owned; + self.interests = next_registered; + for owner in &seen { + self.recent_compat_owner_input_refs.remove(owner); + self.recent_compat_owner_input_ref_sets.remove(owner); + } + self.retire_unreferenced_filters(); + self.sources_dirty = true; + self.pending_backfills.retain(|queued| { + queued + .owner + .as_ref() + .is_none_or(|owner| !seen.contains(owner)) + }); + self.pending_backfills.extend(replacement_backfills); + Ok(()) } -} -/// Catch-up policy applied when staging a transaction-aware interest owner. -#[derive(Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum SubscriberOwnerStart { - /// Start with live delivery only. - Live, - /// Start strictly after an already-applied post-block baseline. + /// Atomically replace every compatibility owner without requesting + /// historical delivery. /// - /// A baseline at block `N` schedules backfill from `N + 1`; block `N` - /// itself is never replayed. Transaction-aware callers explicitly call - /// [`AlloySubscriber::reconcile_interest_owner`] before activation; staged - /// owners never use the legacy lazy-backfill queue. - PostBlock(BlockRef), -} + /// # Errors + /// + /// Returns [`SubscriberError`] for invalid configuration, duplicate owners, + /// mixed lifecycle APIs, unsupported interests, or resource exhaustion. + /// The previous topology remains authoritative on error. + pub fn replace_interest_owners( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + ) -> Result<(), SubscriberError> { + self.replace_interest_owners_inner(owners, None) + } -/// Transaction state of one epoch-scoped subscriber owner. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum SubscriberOwnerState { - /// Desired interests and owner-scoped buffering are installed but canonical - /// routing has not yet committed. - Staged, - /// Canonical runtime routing has committed for this owner. - Active, - /// Removal is prepared behind a delivery fence but remains reversible. - Removing, -} + /// Atomically replace every compatibility owner and queue one global + /// post-baseline backfill for the resulting union of log interests. + /// + /// Base interests are replaced. Epoch-scoped lifecycle operations cannot + /// be mixed with this compatibility replacement because silently deleting + /// an in-flight epoch would violate its activation transaction. + /// + /// # Errors + /// + /// Returns [`SubscriberError`] for invalid configuration, duplicate owners, + /// mixed lifecycle APIs, unsupported interests, or backfill-capacity + /// exhaustion. The previous topology remains authoritative on error. + pub fn replace_interest_owners_with_global_backfill( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + backfill: SubscriberBackfill, + ) -> Result<(), SubscriberError> { + self.replace_interest_owners_inner(owners, Some(backfill)) + } -/// Hash-certified catch-up position reached by one subscriber owner epoch. -/// -/// Progress means every owner-only record through this point has been fetched -/// and queued inside the subscriber. It does not mean the downstream actor has -/// drained or committed those records; that requires a separate delivery fence. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SubscriberOwnerProgress { - owner: SubscriberOwnerEpoch, - through: BlockRef, -} + fn replace_interest_owners_inner( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + backfill: Option, + ) -> Result<(), SubscriberError> { + validate_subscriber_config(&self.config)?; + if self + .owned_interests + .iter() + .any(|entry| entry.epoch.is_some()) + { + return Err(SubscriberError::InvalidConfig( + "cannot replace compatibility owners while an epoch-scoped lifecycle exists", + )); + } -impl SubscriberOwnerProgress { - /// Exact owner epoch whose catch-up was reconciled. - pub const fn owner(&self) -> &SubscriberOwnerEpoch { - &self.owner - } + let mut seen = HashSet::with_capacity(owners.len()); + let mut next_owned = Vec::with_capacity(owners.len()); + for (owner, interests) in owners { + if !seen.insert(owner.clone()) { + return Err(SubscriberError::InvalidConfig( + "owner replacement contains a duplicate owner", + )); + } + next_owned.push(OwnedSubscriberInterests { + owner, + interests, + epoch: None, + state: SubscriberOwnerState::Active, + baseline: None, + progress: None, + progress_stream_revision: None, + }); + } + let next_registered = aggregate_interests(&[], &next_owned); + validate_supported_interests(self.mode, &self.config, &next_registered)?; + let mut filters = log_filters(&next_registered); + let mut unique_filters = Vec::with_capacity(filters.len()); + for filter in filters.drain(..) { + if !unique_filters.contains(&filter) { + unique_filters.push(filter); + } + } + let replacement_backfills: VecDeque<_> = match backfill { + Some(backfill) if !unique_filters.is_empty() => { + VecDeque::from([QueuedSubscriberBackfill { + owner: None, + epoch: None, + filters: unique_filters, + backfill, + }]) + } + Some(_) | None => VecDeque::new(), + }; + let replacement_units = replacement_backfills + .iter() + .map(|queued| queued.filters.len()) + .sum::(); + if replacement_units > self.config.max_pending_backfills { + return Err(SubscriberError::ResourceExhausted(format!( + "owner replacement would queue more than {} lazy backfills", + self.config.max_pending_backfills + ))); + } - /// Verified canonical block through which owner input was fetched. - pub const fn through(&self) -> &BlockRef { - &self.through + // No fallible work remains. The post-baseline range reconstructs every + // delivery after the cache snapshot, so reset all stale delivery and + // dedupe state from the prior topology before publishing the exact + // replacement plus its global historical work. + self.base_interests.clear(); + self.owned_interests = next_owned; + self.interests = next_registered; + self.reset_delivery_state(); + self.pending_backfills = replacement_backfills; + self.state = AlloySubscriberState::Uninitialized; + Ok(()) } -} - -/// Error staging a transaction-aware subscriber owner. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum SubscriberOwnerError { - /// Subscriber configuration or interest validation failed. - #[error(transparent)] - Subscriber(#[from] SubscriberError), - /// The logical owner already has desired interests installed. - #[error("subscriber interest owner `{0}` is already registered")] - AlreadyRegistered(HandlerId), - /// A post-block baseline cannot be advanced to its first unapplied block. - #[error("post-block subscriber baseline {0} has no following block")] - PostBlockOverflow(u64), - /// The monotonic subscriber owner epoch sequence was exhausted. - #[error("subscriber owner epoch sequence exhausted")] - EpochExhausted, - /// The exact owner epoch is unknown or no longer staged. - #[error("subscriber owner epoch is not staged")] - NotStaged, - /// Live-only staging has no historical baseline to reconcile. - #[error("subscriber owner was staged live-only and has no catch-up baseline")] - MissingBaseline, - /// Post-block reconciliation currently covers log interests only. - #[error("post-block subscriber owners support log interests only")] - UnsupportedPostBlockInterest, - /// The target block was absent from the provider. - #[error("subscriber reconcile target block {0} was not found")] - BlockUnavailable(u64), - /// The provider's canonical identity did not match the requested target. - #[error( - "subscriber reconcile target mismatch: expected block {expected_number} {expected_hash}, got block {actual_number} {actual_hash}" - )] - BlockMismatch { - /// Requested block number. - expected_number: u64, - /// Requested block hash. - expected_hash: B256, - /// Provider block number. - actual_number: u64, - /// Provider block hash. - actual_hash: B256, - }, - /// A reconcile target was older than the retained baseline/progress. - #[error("subscriber reconcile target block {target} precedes current owner position {current}")] - ProgressRegression { - /// Retained baseline or progress block. - current: u64, - /// Rejected target block. - target: u64, - }, - /// A reconcile attempted to replace a retained block identity at the same - /// height or cross an immediate parent that does not extend it. - #[error( - "subscriber reconcile conflicts with retained block {number} {current_hash}: target chain references {target_hash}" - )] - ProgressConflict { - /// Retained baseline or progress block number. - number: u64, - /// Retained baseline or progress block hash. - current_hash: B256, - /// Conflicting target hash or immediate parent hash. - target_hash: B256, - }, - /// A provider returned a malformed or out-of-range catch-up log. - #[error("subscriber reconcile returned an invalid catch-up log: {0}")] - InvalidBackfillLog(&'static str), -} -/// Extension trait for subscribers that can add and remove handler-owned -/// interests incrementally. -/// -/// [`EventSubscriber::register_interests`] remains the full-replacement setup -/// API. Implement this trait when a subscriber can preserve unrelated live -/// sources and delivery state while one handler's interests are added or -/// removed. Implementations should make owner *replacement* continuity-safe: -/// updating an owner's interests must not silently discard delivery progress -/// the previous interests had already established (the in-crate -/// [`AlloySubscriber`] carries the owner's prior delivery anchor over to -/// changed filter shapes and automatically backfills the gap). -pub trait InterestOwnerSubscriber: EventSubscriber { /// Add or replace the interests owned by `owner`. - fn add_interest_owner( + /// + /// This preserves unrelated owners, queued/pending records, recent dedupe + /// state, and last-seen log anchors. The live transport is reconciled on the + /// next [`EventSubscriber::next_batch`] call so newly added log filters can + /// be subscribed without rebuilding the whole subscriber object. + /// + /// Replacing an existing owner is continuity-safe: filters the owner + /// already had keep their delivery anchors, and any changed or new filter + /// shape is automatically backfilled from the owner's oldest prior anchor — + /// growing a pool set on an established owner does not open a delivery gap + /// for what the old subscription had already covered. A brand-new owner has + /// no anchor to inherit; pass an explicit + /// [`add_interest_owner_with_backfill`](Self::add_interest_owner_with_backfill) + /// anchor (or register through [`ReactiveEngine::register_handler`], which + /// anchors to the runtime's last canonical block). + /// + /// # Errors + /// + /// Returns [`SubscriberError`] for invalid configuration, incompatible + /// lifecycle state, unsupported interests, or continuity-backfill capacity + /// exhaustion. The prior owner state remains authoritative on error. + pub fn add_interest_owner( &mut self, owner: HandlerId, interests: &[ReactiveInterest], - ) -> Result<(), SubscriberError>; + ) -> Result<(), SubscriberError> { + self.set_interest_owner(owner, interests, None) + } /// Add or replace owner interests and schedule log backfill for that owner. - fn add_interest_owner_with_backfill( + /// + /// Backfill is queued only for log interests; block and pending transaction + /// interests are live-only. Queued records can be delivered immediately; + /// the subsequent provider stream is then caught up from the seeded + /// delivery anchor, and overlap is deduplicated — so the discovery boundary + /// is closed end to end as long + /// as `backfill` starts at (or before) the block the interest was + /// discovered in. Continuity backfill for a replaced owner (see + /// [`add_interest_owner`](Self::add_interest_owner)) is queued in addition, + /// unless this explicit backfill is open-ended and already starts at or + /// below the owner's prior anchor. + /// + /// # Errors + /// + /// Returns [`SubscriberError`] for invalid configuration, incompatible + /// lifecycle state, unsupported interests, or backfill-capacity exhaustion. + /// The prior owner state remains authoritative on error. + pub fn add_interest_owner_with_backfill( &mut self, owner: HandlerId, interests: &[ReactiveInterest], backfill: SubscriberBackfill, - ) -> Result<(), SubscriberError>; - - /// Remove one owner's interests, preserving unrelated interests. - fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option>>; - - /// Borrow the interests currently owned by `owner`. - fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest]>; -} - -/// Binds a [`ReactiveRuntime`] to an [`EventSubscriber`] for the common -/// subscribe-ingest lifecycle. -/// -/// The engine treats the runtime registry as the single source of truth for -/// handler lifecycle: [`register_handler`](Self::register_handler) and -/// [`unregister_handler`](Self::unregister_handler) update runtime routing and -/// subscriber interests as one operation, keyed by the handler's stable -/// [`HandlerId`]. Registration is continuity-safe by default — once the runtime -/// has journaled a canonical block, a newly registered handler is backfilled -/// from that block, so a pool discovered in block *N* (say via a factory -/// `PoolCreated` event) misses none of its own logs from *N* onward even though -/// its live subscription starts later. Overlap between backfill and live -/// delivery is absorbed by subscriber and runtime dedup. -/// -/// Registration methods by intent: -/// -/// | Method | Backfill | -/// |---|---| -/// | [`register_handler`](Self::register_handler) | from the runtime's last canonical block (live-only on a fresh runtime) | -/// | [`register_handler_with_backfill`](Self::register_handler_with_backfill) | explicit range or anchor (deep history) | -/// | [`register_handler_live_only`](Self::register_handler_live_only) | none — future logs only | -/// -/// Unregistering a handler stops future subscription routing and runtime -/// decode for that handler; it deliberately does not evict [`EvmCache`] state -/// or undo runtime side effects. See -/// [`unregister_handler`](Self::unregister_handler) for the complete teardown -/// recipe. -/// -/// The runtime and subscriber stay independently accessible through -/// [`runtime_mut`](Self::runtime_mut) / [`subscriber_mut`](Self::subscriber_mut) -/// for advanced use. One caution: avoid calling -/// [`EventSubscriber::register_interests`] (the full-replacement setup API) on -/// an engine-managed subscriber — implementations may clear owner-scoped -/// bookkeeping, after which per-handler unregistration no longer releases the -/// handler's transport subscriptions. To bootstrap the subscriber from a -/// runtime that already has handlers, use -/// [`sync_handler_interests`](Self::sync_handler_interests), which registers -/// one owner per handler instead of one unowned blob. -pub struct ReactiveEngine { - runtime: ReactiveRuntime, - subscriber: S, -} - -impl ReactiveEngine -where - N: Network, - S: EventSubscriber, -{ - /// Bind a runtime and subscriber. - pub fn new(runtime: ReactiveRuntime, subscriber: S) -> Self { - Self { - runtime, - subscriber, - } + ) -> Result<(), SubscriberError> { + self.set_interest_owner(owner, interests, Some(backfill)) } - /// Split the engine into its runtime and subscriber parts. - pub fn into_parts(self) -> (ReactiveRuntime, S) { - (self.runtime, self.subscriber) - } + /// Add or replace one owner at retained canonical block `C`, then queue the + /// coordinated cutover required by [`ReactiveEngine::register_handler`]. + /// + /// The new owner alone receives matching records from `C` so its effects + /// attach to the runtime's existing journal entry. Every matching log from + /// `C + 1` through the activation head is then delivered canonically over + /// the complete interest union. [`Self::next_scoped_batch`] installs the + /// desired live streams before draining either window, closing the + /// subscribe/backfill gap. Alloy cannot reconstruct historical block or + /// pending-transaction deliveries through this log backfill path, so a + /// mixed interest topology is rejected rather than silently underfilled. + /// + /// # Errors + /// + /// Returns [`SubscriberError`] for invalid configuration, incompatible + /// lifecycle state, unsupported non-log catch-up, block-number overflow, or + /// resource exhaustion. The prior owner state remains authoritative on + /// error. + pub fn add_interest_owner_with_canonical_catchup( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + retained: BlockRef, + ) -> Result<(), SubscriberError> { + validate_subscriber_config(&self.config)?; + if self + .owned_interests + .iter() + .any(|entry| entry.owner == owner && entry.epoch.is_some()) + { + return Err(SubscriberError::InvalidConfig( + "cannot mix compatibility and epoch-scoped owner lifecycle APIs", + )); + } - /// Borrow the runtime. - pub fn runtime(&self) -> &ReactiveRuntime { - &self.runtime - } + let mut next_owned = self.clone_owned_interests(); + if let Some(entry) = next_owned.iter_mut().find(|entry| entry.owner == owner) { + entry.interests = interests.to_vec(); + entry.state = SubscriberOwnerState::Active; + entry.baseline = None; + entry.progress = None; + entry.progress_stream_revision = None; + entry.epoch = None; + } else { + next_owned.push(OwnedSubscriberInterests { + owner: owner.clone(), + interests: interests.to_vec(), + epoch: None, + state: SubscriberOwnerState::Active, + baseline: None, + progress: None, + progress_stream_revision: None, + }); + } + let next_registered = aggregate_interests(&self.base_interests, &next_owned); + validate_supported_interests(self.mode, &self.config, &next_registered)?; + if next_registered + .iter() + .any(|interest| !matches!(interest, ReactiveInterest::Logs(_))) + { + return Err(SubscriberError::Unsupported( + "Alloy coordinated registration supports log-only interest topologies", + )); + } - /// Mutably borrow the runtime. - pub fn runtime_mut(&mut self) -> &mut ReactiveRuntime { - &mut self.runtime - } + let mut owner_filters = Vec::new(); + for filter in log_filters(interests) { + if !owner_filters.contains(&filter) { + owner_filters.push(filter); + } + } + let mut global_filters = Vec::new(); + for filter in log_filters(&next_registered) { + if !global_filters.contains(&filter) { + global_filters.push(filter); + } + } + let owner_backfill = + SubscriberBackfill::from_canonical_block_through(retained, retained.number)?; + let global_backfill = SubscriberBackfill::after_canonical_block(retained)?; + let replacement_units = owner_filters.len().saturating_add(global_filters.len()); + let retained_units = self + .pending_backfills + .iter() + .filter(|queued| queued.owner.as_ref() != Some(&owner)) + .map(|queued| queued.filters.len()) + .sum::(); + if retained_units.saturating_add(replacement_units) > self.config.max_pending_backfills { + return Err(SubscriberError::ResourceExhausted(format!( + "coordinated owner registration would queue more than {} lazy backfills", + self.config.max_pending_backfills + ))); + } - /// Borrow the subscriber. - pub fn subscriber(&self) -> &S { - &self.subscriber - } + let mut replacement_backfills = VecDeque::new(); + if !owner_filters.is_empty() { + replacement_backfills.push_back(QueuedSubscriberBackfill { + owner: Some(owner.clone()), + epoch: None, + filters: owner_filters, + backfill: owner_backfill, + }); + } + // Keep the global certification job even for an empty filter union: it + // advances canonical coverage through a zero-event registration window. + replacement_backfills.push_back(QueuedSubscriberBackfill { + owner: None, + epoch: None, + filters: global_filters, + backfill: global_backfill, + }); - /// Mutably borrow the subscriber. - pub fn subscriber_mut(&mut self) -> &mut S { - &mut self.subscriber + // Every fallible preflight is complete. Publish topology and both + // ordered windows as one synchronous local commit. + self.owned_interests = next_owned; + self.interests = next_registered; + self.recent_compat_owner_input_refs.remove(&owner); + self.recent_compat_owner_input_ref_sets.remove(&owner); + self.pending_backfills + .retain(|queued| queued.owner.as_ref() != Some(&owner)); + self.pending_backfills.extend(replacement_backfills); + self.retire_unreferenced_filters(); + self.sources_dirty = true; + Ok(()) } - /// Poll the subscriber for the next batch. - pub fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> { - self.subscriber.next_batch() + /// Remove one owner's interests, preserving unrelated owner/base interests. + /// + /// The owner's queued backfills are dropped, and source-id/anchor + /// bookkeeping for filters no other owner references is retired. Live + /// streams for retired filters are torn down on the next + /// [`EventSubscriber::next_batch`] call (dropping an Alloy subscription + /// unsubscribes provider-side); events already in flight from them stop + /// matching the merged interest set and are discarded. + pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option>> { + let index = self + .owned_interests + .iter() + .position(|entry| &entry.owner == owner && entry.epoch.is_none())?; + let removed = self.owned_interests.remove(index); + if let Some(epoch) = &removed.epoch { + self.purge_owner_epoch(epoch); + } else { + self.pending_backfills + .retain(|backfill| backfill.owner.as_ref() != Some(owner)); + self.recent_compat_owner_input_refs.remove(owner); + self.recent_compat_owner_input_ref_sets.remove(owner); + } + self.rebuild_registered_interests(); + self.retire_unreferenced_filters(); + self.sources_dirty = true; + Some(removed.interests) } - /// Ingest one already-polled batch through the runtime (direct effects - /// only; surfaced resync requests are reported, not executed). - pub fn ingest_batch( - &mut self, - cache: &mut EvmCache, - batch: ReactiveInputBatch, - ) -> Result, ReactiveError> { - self.runtime.ingest_batch(cache, batch) + /// Borrow the interests currently owned by `owner`. + pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest]> { + self.owned_interests + .iter() + .find(|entry| &entry.owner == owner) + .map(|entry| entry.interests.as_slice()) } - /// Ingest one already-polled batch and execute the storage/account resyncs - /// it surfaces, exactly like - /// [`ReactiveRuntime::ingest_batch_with_resync`]. - pub fn ingest_batch_with_resync( + fn set_interest_owner( &mut self, - cache: &mut EvmCache, - batch: ReactiveInputBatch, - ) -> Result, ReactiveError> { - self.runtime.ingest_batch_with_resync(cache, batch) - } + owner: HandlerId, + interests: &[ReactiveInterest], + backfill: Option, + ) -> Result<(), SubscriberError> { + validate_subscriber_config(&self.config)?; + if self + .owned_interests + .iter() + .any(|entry| entry.owner == owner && entry.epoch.is_some()) + { + return Err(SubscriberError::InvalidConfig( + "cannot mix compatibility and epoch-scoped owner lifecycle APIs", + )); + } - /// Poll the subscriber once and ingest the returned batch when present - /// (direct effects only). - pub async fn next_ingest( - &mut self, - cache: &mut EvmCache, - ) -> Result>, ReactiveEngineError> { - let Some(batch) = self.subscriber.next_batch().await? else { - return Ok(None); + let mut next_owned = self.clone_owned_interests(); + let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) { + Some(entry) => { + entry.interests = interests.to_vec(); + entry.state = SubscriberOwnerState::Active; + entry.baseline = None; + entry.progress = None; + entry.progress_stream_revision = None; + entry.epoch.take() + } + None => { + next_owned.push(OwnedSubscriberInterests { + owner: owner.clone(), + interests: interests.to_vec(), + epoch: None, + state: SubscriberOwnerState::Active, + baseline: None, + progress: None, + progress_stream_revision: None, + }); + None + } }; - Ok(Some(self.runtime.ingest_batch(cache, batch)?)) - } + let next_registered = aggregate_interests(&self.base_interests, &next_owned); + validate_supported_interests(self.mode, &self.config, &next_registered)?; + + // Continuity capture, before the mutation lands: the owner's previous + // filter shapes and the oldest delivery anchor among them. A changed + // filter gets a fresh source id with no anchor, so without this + // hand-off, replacing an owner's interests (the normal way to grow a + // pool set) would silently discard the delivery watermark and open a + // gap until some later explicit backfill. + let previous_filters: Vec = self + .owner_interests(&owner) + .map(log_filters) + .unwrap_or_default(); + let continuity_anchor: Option = previous_filters + .iter() + .filter_map(|filter| self.log_anchor(filter)) + .min(); + + // Build the replacement queue before committing owner state. Capacity + // failure is therefore atomic and cannot leave desired interests ahead + // of the historical work required to make them continuous. + let mut replacement_backfills = Vec::new(); + let filters = log_filters(interests); + if let Some(backfill) = backfill + && !filters.is_empty() + { + replacement_backfills.push(QueuedSubscriberBackfill { + owner: Some(owner.clone()), + epoch: None, + filters: filters.clone(), + backfill, + }); + } + let explicit_covers = backfill.is_some_and(|explicit| { + explicit.end_block().is_none() + && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor) + }); + let continuity_filters: Vec<_> = filters + .into_iter() + .filter(|filter| !previous_filters.contains(filter)) + .collect(); + if let Some(anchor) = continuity_anchor + && !continuity_filters.is_empty() + && !explicit_covers + { + replacement_backfills.push(QueuedSubscriberBackfill { + owner: Some(owner.clone()), + epoch: None, + filters: continuity_filters, + backfill: SubscriberBackfill::from_block(anchor), + }); + } + let retained_backfills = self + .pending_backfills + .iter() + .filter(|queued| queued.owner.as_ref() != Some(&owner)) + .map(|queued| queued.filters.len()) + .sum::(); + let replacement_units = replacement_backfills + .iter() + .map(|queued| queued.filters.len()) + .sum::(); + if retained_backfills.saturating_add(replacement_units) > self.config.max_pending_backfills + { + return Err(SubscriberError::ResourceExhausted(format!( + "owner update would queue more than {} lazy backfills", + self.config.max_pending_backfills + ))); + } - /// Poll the subscriber once and ingest the returned batch with resync - /// execution — the loop shape for consumers that rely on coverage-gap - /// repair (root-gate resyncs, handler-requested re-reads). - pub async fn next_ingest_with_resync( - &mut self, - cache: &mut EvmCache, - ) -> Result>, ReactiveEngineError> { - let Some(batch) = self.subscriber.next_batch().await? else { - return Ok(None); - }; - Ok(Some(self.runtime.ingest_batch_with_resync(cache, batch)?)) - } -} + self.owned_interests = next_owned; + self.interests = next_registered; + if let Some(epoch) = replaced_epoch { + self.purge_owner_epoch(&epoch); + } else { + self.recent_compat_owner_input_refs.remove(&owner); + self.recent_compat_owner_input_ref_sets.remove(&owner); + } + self.retire_unreferenced_filters(); + self.sources_dirty = true; -impl ReactiveEngine -where - N: Network, - S: InterestOwnerSubscriber, -{ - /// Register a handler with both the runtime and subscriber, backfilling its - /// log interests from the runtime's last canonical block. - /// - /// This is the continuity-safe default for mid-lifecycle registration: the - /// runtime already knows how far it has processed the chain, so the new - /// handler's logs are fetched from that block forward and no discovery gap - /// opens between "we decided to track this pool" and "its live subscription - /// started". On a runtime that has not journaled any canonical block yet - /// (fresh start, or `journal_depth` 0) registration is live-only, matching - /// pre-ingestion bootstrap. Use - /// [`register_handler_with_backfill`](Self::register_handler_with_backfill) - /// for deeper history or - /// [`register_handler_live_only`](Self::register_handler_live_only) to opt - /// out of backfill entirely. - /// - /// If subscriber registration fails, the runtime registration is rolled back - /// before the error is returned. - pub fn register_handler( - &mut self, - handler: Arc>, - ) -> Result<(), ReactiveEngineRegisterError> { - let backfill = self - .runtime - .last_canonical_block() - .map(|block| SubscriberBackfill::from_block(block.number)); - self.register_handler_inner(handler, backfill) + // Re-queue this owner's backfills from scratch: previously queued + // entries may reference filter shapes that no longer exist. + self.pending_backfills + .retain(|queued| queued.owner.as_ref() != Some(&owner)); + self.pending_backfills.extend(replacement_backfills); + Ok(()) } - /// Register a handler and request an explicit owner-scoped log backfill for - /// its interests (deep history / custom anchors). - /// - /// If subscriber registration fails, the runtime registration is rolled back - /// before the error is returned. - pub fn register_handler_with_backfill( - &mut self, - handler: Arc>, - backfill: SubscriberBackfill, - ) -> Result<(), ReactiveEngineRegisterError> { - self.register_handler_inner(handler, Some(backfill)) + fn clone_owned_interests(&self) -> Vec> { + self.owned_interests + .iter() + .map(|entry| OwnedSubscriberInterests { + owner: entry.owner.clone(), + interests: entry.interests.clone(), + epoch: entry.epoch.clone(), + state: entry.state, + baseline: entry.baseline, + progress: entry.progress.clone(), + progress_stream_revision: entry.progress_stream_revision, + }) + .collect() } - /// Register a handler without any log backfill — only logs delivered after - /// its live subscription starts are routed to it. - /// - /// If subscriber registration fails, the runtime registration is rolled back - /// before the error is returned. - pub fn register_handler_live_only( - &mut self, - handler: Arc>, - ) -> Result<(), ReactiveEngineRegisterError> { - self.register_handler_inner(handler, None) + fn rebuild_registered_interests(&mut self) { + self.interests = aggregate_interests(&self.base_interests, &self.owned_interests); } - fn register_handler_inner( - &mut self, - handler: Arc>, - backfill: Option, - ) -> Result<(), ReactiveEngineRegisterError> { - let id = handler.id(); - self.runtime.register_handler(handler)?; - let interests = self - .runtime - .handler_interests(&id) - .expect("handler was just registered") - .to_vec(); - - let subscribed = match backfill { - Some(backfill) => { - self.subscriber - .add_interest_owner_with_backfill(id.clone(), &interests, backfill) - } - None => self.subscriber.add_interest_owner(id.clone(), &interests), - }; - if let Err(error) = subscribed { - self.runtime.unregister_handler(&id); - return Err(error.into()); + /// Delivery anchor (last block known fully delivered) for `filter`, if the + /// filter has a source id and has seen delivery. + fn log_anchor(&self, filter: &Filter) -> Option { + if let Some(anchor) = self + .log_source_ids + .get(filter) + .and_then(|id| self.last_seen_log_blocks.get(id)) + { + return Some(*anchor); } - Ok(()) + // Logical owner filters may be represented by a broader provider + // stream after fan-in. Its oldest live watermark is a conservative + // continuity anchor: it can cause extra backfill, never a missed log. + self.log_source_ids + .values() + .filter_map(|id| self.last_seen_log_blocks.get(id).copied()) + .min() } - /// Register every handler currently in the runtime registry as a subscriber - /// interest owner. - /// - /// This is the bootstrap path for an engine built around a pre-populated - /// runtime: each handler becomes its own owner (upsert semantics, so - /// rerunning is safe and already-registered owners are refreshed in place). - /// No backfill is requested — bootstrap happens before ingestion starts, so - /// there is no processed position to be continuous with; use - /// [`register_handler_with_backfill`](Self::register_handler_with_backfill) - /// for handlers that need history. Owners are not removed by this call: use - /// [`unregister_handler`](Self::unregister_handler) for lifecycle removal - /// rather than mutating the runtime registry directly. - /// - /// On error, owners already synced stay registered (upserts are - /// independent); the call can simply be retried. - pub fn sync_handler_interests(&mut self) -> Result<(), SubscriberError> { - for id in self.runtime.handler_ids() { - let interests = self - .runtime - .handler_interests(&id) - .map(<[ReactiveInterest]>::to_vec) - .unwrap_or_default(); - self.subscriber.add_interest_owner(id, &interests)?; + /// Every logical log filter across base and owner interests, merged within + /// each origin and deduplicated across origins. These shapes remain the + /// exact routing and owner-continuity boundary; provider subscriptions may + /// fan several of them into one broader filter. + // `Filter` derives `Hash`/`Eq` and has no interior mutability; the + // `mutable_key_type` lint is a known false positive for it. + #[allow(clippy::mutable_key_type)] + fn logical_log_filters(&self) -> Vec { + let mut filters = log_filters(&self.base_interests); + for entry in &self.owned_interests { + filters.extend(log_filters(&entry.interests)); } - Ok(()) - } - - /// Unregister a handler from both the subscriber and runtime. - /// - /// Subscriber interests are removed first so no new live records are routed - /// to a handler after it has left the runtime registry. Returns the removed - /// handler when the id was registered. - /// - /// This is the routing/transport half of dropping an adapter. State the - /// handler accumulated is deliberately left in place; the complete teardown - /// for a pool or adapter that will not return is: - /// - /// ```text - /// engine.unregister_handler(&id); - /// for request_id in handler_request_ids { - /// // Drop only this handler generation's queued repair work. - /// engine.runtime_mut().cancel_pending_resync(&request_id); - /// } - /// for address in exclusively_owned_addresses { - /// // Shared accounts require caller-side owner reference counting. - /// engine.runtime_mut().untrack_account(address); - /// } - /// // optional: evict cached state via StateUpdate::purge / cache purge APIs - /// ``` - /// - /// Health, metrics, the reorg journal, hooks, and freshness stamps are - /// runtime-global and are never touched by handler removal. - pub fn unregister_handler(&mut self, id: &HandlerId) -> Option>> { - self.subscriber.remove_interest_owner(id); - self.runtime.unregister_handler(id) + let mut seen = HashSet::new(); + filters.retain(|filter| seen.insert(filter.clone())); + filters } -} - -/// Alloy-backed event subscriber. -/// -/// The default transport slice drives Alloy pubsub subscriptions for logs, -/// block headers, and pending transaction hashes. The HTTP polling `watch_*` -/// transport remains available behind the opt-in `reactive-polling` feature. -/// Pubsub streams reconnect automatically after termination, and log -/// subscriptions are backfilled from the last seen block. Owner-scoped log -/// additions can request backfill from an explicit block anchor. Full pending -/// transaction hydration and full block bodies remain explicit follow-up work. -/// With no registered interests, [`EventSubscriber::next_batch`] returns -/// `Ok(None)`. -pub struct AlloySubscriber { - provider: P, - mode: SubscriberMode, - config: SubscriberConfig, - base_interests: Vec>, - owned_interests: Vec>, - next_owner_epoch: u64, - interests: Vec>, - /// Stable source id per distinct provider-facing log filter. Ids key - /// delivery anchors and live `SubscriberEvent`s; entries are retired (and - /// their anchors pruned) when no planned stream references the filter, so - /// long-lived owner churn cannot grow this map unboundedly. - log_source_ids: HashMap, - next_log_source_id: usize, - pending_backfills: VecDeque, - /// Set when interest bookkeeping changed since the last successful stream - /// reconcile, so steady-state polling skips the desired-vs-live diff. - sources_dirty: bool, - /// Conservative generation of desired/live stream topology. Successful - /// owner progress is activatable only against the same clean revision. - stream_revision: u64, - state: AlloySubscriberState, - pending_records: VecDeque>, - /// Owner copies of live records consumed during an in-flight reconcile. - /// These remain hidden from subscriber output until the owning reconcile - /// commits and survive cancellation so subscribe-first adoption cannot - /// lose an event at an await boundary. - pending_reconcile_owner_records: VecDeque>, - last_seen_log_blocks: HashMap, - recent_input_refs: VecDeque, - recent_input_ref_set: HashSet, - recent_owner_input_refs: HashMap>, - recent_owner_input_ref_sets: HashMap>, - _network: PhantomData, -} - -struct OwnedSubscriberInterests { - owner: HandlerId, - interests: Vec>, - epoch: Option, - state: SubscriberOwnerState, - baseline: Option, - progress: Option, - progress_stream_revision: Option, -} -#[derive(Clone)] -struct SubscriberOwnerReconcilePlan { - epoch: SubscriberOwnerEpoch, - interests: Vec>, - retained: BlockRef, - from_block: u64, -} + /// Provider-facing log filters. Compatible logical filters fan into a + /// small number of address/topic supersets, then split only when the + /// configured address ceiling requires it. Exact matching remains local in + /// `enqueue_event`, so this reduces subscriptions without broadening owner + /// delivery. + fn log_stream_filters(&self) -> Vec { + let mut merged = Vec::new(); + for filter in self.logical_log_filters() { + merge_log_subscription_filter(&mut merged, &filter); + } -struct SubscriberOwnerCatchup { - logs: Vec, - certified: BlockRef, -} + let max_addresses = self.config.max_log_addresses_per_subscription.max(1); + let mut planned = Vec::new(); + for filter in merged { + let mut addresses: Vec<_> = filter.address.iter().copied().collect(); + if addresses.len() <= max_addresses { + planned.push(filter); + continue; + } + addresses.sort_unstable(); + for chunk in addresses.chunks(max_addresses) { + let mut split = filter.clone(); + split.address = FilterSet::default(); + for address in chunk { + split.address.insert(*address); + } + planned.push(split); + } + } + planned + } -struct SubscriberOwnerReconcileFilter { - filter: Filter, - from_block: u64, -} + /// Drop source-id and anchor bookkeeping for filters no longer referenced + /// by any base or owner interest, so long-lived owner churn cannot grow the + /// maps unboundedly. Live streams for retired filters are pruned by the + /// next reconcile. + // `Filter` derives `Hash`/`Eq` and has no interior mutability; the + // `mutable_key_type` lint is a known false positive for it. + #[allow(clippy::mutable_key_type)] + fn retire_unreferenced_filters(&mut self) { + let mut live: HashSet = self.log_stream_filters().into_iter().collect(); + if let AlloySubscriberState::Active(streams) = &self.state { + for entry in &streams.entries { + match &entry.source { + SubscriberStreamSource::PubSubLog { filter, .. } + | SubscriberStreamSource::BasePendingLog { filter, .. } + | SubscriberStreamSource::PollingLog { filter } => { + live.insert(filter.clone()); + } + SubscriberStreamSource::BaseFlashblocks + | SubscriberStreamSource::OpPendingFlashblocks + | SubscriberStreamSource::PubSubPendingHashes + | SubscriberStreamSource::PubSubBlockHeaders + | SubscriberStreamSource::PollingPendingHashes => {} + } + } + } + self.log_source_ids + .retain(|filter, _| live.contains(filter)); + let live_ids: HashSet = self.log_source_ids.values().copied().collect(); + self.last_seen_log_blocks + .retain(|id, _| live_ids.contains(id)); + } -struct BufferedSubscriberOwnerRecord { - record: ReactiveInputRecord, - owners: Vec, -} + fn drain_next_scoped_batch(&mut self) -> Option> { + if self.pending_records.is_empty() && self.pending_chain_controls.is_empty() { + return None; + } -const OWNER_RECONCILE_FILTERS_PER_CHUNK: usize = 256; + let first_preconfirmation = self.pending_records.front().and_then(|record| { + if record.scope != SubscriberInputScope::Preconfirmed { + return None; + } + match &record.record.context.chain_status { + ChainStatus::Preconfirmed { flashblock } => Some(flashblock.clone()), + _ => None, + } + }); + let len = self + .pending_records + .iter() + .take(self.config.max_batch_size) + .take_while(|record| match &first_preconfirmation { + Some(expected) => { + record.scope == SubscriberInputScope::Preconfirmed + && matches!( + &record.record.context.chain_status, + ChainStatus::Preconfirmed { flashblock } if flashblock == expected + ) + } + None => record.scope != SubscriberInputScope::Preconfirmed, + }) + .count(); + let records = self.pending_records.drain(..len).collect(); + let chain_controls = if first_preconfirmation.is_none() && self.pending_records.is_empty() { + self.pending_chain_controls.drain(..).collect() + } else { + Vec::new() + }; + Some(SubscriberInputBatch { + records, + chain_id: self.chain_id, + chain_controls, + }) + } -struct QueuedSubscriberBackfill { - owner: HandlerId, - epoch: Option, - filter: Filter, - backfill: SubscriberBackfill, -} + fn reset_delivery_state(&mut self) { + self.pending_records.clear(); + self.pending_chain_controls.clear(); + self.pending_reconcile_owner_records.clear(); + self.resource_error = None; + self.last_seen_log_blocks.clear(); + self.verified_log_blocks.clear(); + self.verified_log_block_order.clear(); + self.recent_input_refs.clear(); + self.recent_input_ref_set.clear(); + self.recent_owner_input_refs.clear(); + self.recent_owner_input_ref_sets.clear(); + self.recent_compat_owner_input_refs.clear(); + self.recent_compat_owner_input_ref_sets.clear(); + self.pending_backfills.clear(); + self.pending_source_backfills.clear(); + self.log_source_ids.clear(); + self.next_log_source_id = 0; + self.sources_dirty = true; + self.reset_flashblock_tracking(); + } -/// Best-effort installation of rustls' `ring` crypto provider as the process -/// default, so an `wss://` TLS handshake under `reactive-ws` does not panic with -/// "no process-level CryptoProvider available". Runs at most once and ignores the -/// error if a default provider is already installed (the host app may have set -/// its own). -#[cfg(feature = "reactive-ws")] -fn ensure_ring_crypto_provider() { - use std::sync::Once; - static INSTALL: Once = Once::new(); - INSTALL.call_once(|| { - let _ = rustls::crypto::ring::default_provider().install_default(); - }); + fn reset_flashblock_tracking(&mut self) { + self.base_flashblock_header = None; + self.flashblocks_by_hash.clear(); + self.flashblock_hash_order.clear(); + self.unmatched_pending_logs.clear(); + self.latest_preconfirmation = None; + self.preconfirmed_seen_logs.clear(); + } + + fn bump_stream_revision(&mut self) { + self.stream_revision = self.stream_revision.saturating_add(1); + } } -impl AlloySubscriber { - /// Create a new Alloy subscriber. - pub fn new(provider: P, mode: SubscriberMode, config: SubscriberConfig) -> Self { - #[cfg(feature = "reactive-ws")] - ensure_ring_crypto_provider(); - Self { - provider, - mode, - config, - base_interests: Vec::new(), - owned_interests: Vec::new(), - next_owner_epoch: 0, - interests: Vec::new(), - log_source_ids: HashMap::new(), - next_log_source_id: 0, - pending_backfills: VecDeque::new(), - sources_dirty: true, - stream_revision: 0, - state: AlloySubscriberState::Uninitialized, - pending_records: VecDeque::new(), - pending_reconcile_owner_records: VecDeque::new(), - last_seen_log_blocks: HashMap::new(), - recent_input_refs: VecDeque::new(), - recent_input_ref_set: HashSet::new(), - recent_owner_input_refs: HashMap::new(), - recent_owner_input_ref_sets: HashMap::new(), - _network: PhantomData, - } +impl InterestOwnerSubscriber for AlloySubscriber +where + P: Provider + Send + Sync, + N: Network + 'static, + N::HeaderResponse: Send + 'static, +{ + fn upsert_interest_owners( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if !owners.is_empty() { + self.ensure_chain_id().await?; + } + AlloySubscriber::upsert_interest_owners(self, owners) + }) } - /// Borrow the provider. - pub fn provider(&self) -> &P { - &self.provider + fn replace_interest_owners( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if owners.iter().any(|(_, interests)| !interests.is_empty()) { + self.ensure_chain_id().await?; + } + AlloySubscriber::replace_interest_owners(self, owners) + }) } - /// Subscriber mode. - pub fn mode(&self) -> SubscriberMode { - self.mode + fn replace_interest_owners_with_global_backfill( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + backfill: SubscriberBackfill, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if owners.iter().any(|(_, interests)| !interests.is_empty()) { + self.ensure_chain_id().await?; + } + AlloySubscriber::replace_interest_owners_with_global_backfill(self, owners, backfill) + }) } - /// Subscriber config. - pub fn config(&self) -> &SubscriberConfig { - &self.config + fn add_interest_owner( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + if !interests.is_empty() { + self.ensure_chain_id().await?; + } + AlloySubscriber::add_interest_owner(self, owner, &interests) + }) } - /// Registered interests across base and owner-scoped registrations. - pub fn registered_interests(&self) -> &[ReactiveInterest] { - &self.interests + fn add_interest_owner_with_backfill( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + backfill: SubscriberBackfill, + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + if !interests.is_empty() { + self.ensure_chain_id().await?; + } + AlloySubscriber::add_interest_owner_with_backfill(self, owner, &interests, backfill) + }) } - /// Stage a fresh, epoch-scoped interest owner without making its inputs - /// canonically routable yet. - /// - /// The returned token is required by every later lifecycle operation. A - /// staged owner participates in provider subscription planning immediately, - /// while its matching input remains owner-scoped until - /// [`activate_interest_owner`](Self::activate_interest_owner) succeeds. - /// Post-block owners require hash-certified - /// [`reconcile_interest_owner`](Self::reconcile_interest_owner) progress on - /// the current clean stream revision before activation. - pub fn stage_interest_owner( + fn add_interest_owner_with_canonical_catchup( &mut self, owner: HandlerId, interests: &[ReactiveInterest], - start: SubscriberOwnerStart, - ) -> Result { - validate_subscriber_config(&self.config)?; - if matches!(&start, SubscriberOwnerStart::PostBlock(_)) - && interests - .iter() - .any(|interest| !matches!(interest, ReactiveInterest::Logs(_))) - { - return Err(SubscriberOwnerError::UnsupportedPostBlockInterest); - } - if self - .owned_interests - .iter() - .any(|entry| entry.owner == owner) - { - return Err(SubscriberOwnerError::AlreadyRegistered(owner)); - } + retained: BlockRef, + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + // Resolve provider identity before the synchronous topology commit; + // cancellation or failure at this await leaves prior state intact. + self.ensure_chain_id().await?; + AlloySubscriber::add_interest_owner_with_canonical_catchup( + self, owner, &interests, retained, + ) + }) + } - let mut next_owned = self.clone_owned_interests(); - next_owned.push(OwnedSubscriberInterests { - owner: owner.clone(), - interests: interests.to_vec(), - epoch: None, - state: SubscriberOwnerState::Staged, - baseline: None, - progress: None, - progress_stream_revision: None, - }); - let next_registered = aggregate_interests(&self.base_interests, &next_owned); - validate_supported_interests(self.mode, &self.config, &next_registered)?; + fn remove_interest_owner( + &mut self, + owner: &HandlerId, + ) -> SubscriberOperation<'_, Option>>> { + let owner = owner.clone(); + Box::pin(async move { Ok(AlloySubscriber::remove_interest_owner(self, &owner)) }) + } - let baseline = match start { - SubscriberOwnerStart::Live => None, - SubscriberOwnerStart::PostBlock(block) => { - block - .number - .checked_add(1) - .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?; - Some(block) - } - }; - let sequence = self - .next_owner_epoch - .checked_add(1) - .ok_or(SubscriberOwnerError::EpochExhausted)?; - let epoch = SubscriberOwnerEpoch { - owner: owner.clone(), - sequence, - }; + fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest]> { + AlloySubscriber::owner_interests(self, owner) + } +} - self.next_owner_epoch = sequence; - let entry = next_owned - .last_mut() - .expect("staged owner was appended during preflight"); - entry.epoch = Some(epoch.clone()); - entry.baseline = baseline; - self.owned_interests = next_owned; - self.interests = next_registered; - self.sources_dirty = true; +enum AlloySubscriberState { + Uninitialized, + Active(SubscriberStreams), + Empty, +} + +struct SubscriberStreams { + entries: Vec>, + next_index: usize, +} + +struct SubscriberStreamEntry { + source: SubscriberStreamSource, + stream: BoxStream<'static, SubscriberEvent>, +} + +impl SubscriberStreams { + fn new() -> Self { + Self { + entries: Vec::new(), + next_index: 0, + } + } - Ok(epoch) + fn is_empty(&self) -> bool { + self.entries.is_empty() } - /// Stage replacement interests for one currently active logical owner. - /// - /// The active epoch remains canonical while the replacement reconciles. - /// Commit both epochs atomically with - /// [`commit_interest_owner_replacement`](Self::commit_interest_owner_replacement), - /// or abort the staged epoch with [`abort_interest_owner`](Self::abort_interest_owner). - pub fn stage_interest_owner_replacement( + fn push( &mut self, - owner: HandlerId, - interests: &[ReactiveInterest], - start: SubscriberOwnerStart, - ) -> Result { - validate_subscriber_config(&self.config)?; - if matches!(&start, SubscriberOwnerStart::PostBlock(_)) - && interests - .iter() - .any(|interest| !matches!(interest, ReactiveInterest::Logs(_))) - { - return Err(SubscriberOwnerError::UnsupportedPostBlockInterest); - } - let active_count = self - .owned_interests + source: SubscriberStreamSource, + stream: BoxStream<'static, SubscriberEvent>, + ) { + self.entries.push(SubscriberStreamEntry { source, stream }); + } + + #[cfg(test)] + fn len(&self) -> usize { + self.entries.len() + } + + fn contains_source(&self, source: &SubscriberStreamSource) -> bool { + self.entries .iter() - .filter(|entry| entry.owner == owner && entry.state == SubscriberOwnerState::Active) - .count(); - if active_count != 1 - || self - .owned_interests - .iter() - .any(|entry| entry.owner == owner && entry.state != SubscriberOwnerState::Active) - { - return Err(SubscriberOwnerError::AlreadyRegistered(owner)); + .any(|entry| entry.source.same_key(source)) + } + + fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) { + self.entries + .retain(|entry| sources.iter().any(|source| entry.source.same_key(source))); + self.normalize_next_index(); + } + + fn normalize_next_index(&mut self) { + if self.entries.is_empty() { + self.next_index = 0; + } else if self.next_index >= self.entries.len() { + self.next_index %= self.entries.len(); } + } - let mut next_owned = self.clone_owned_interests(); - next_owned.push(OwnedSubscriberInterests { - owner: owner.clone(), - interests: interests.to_vec(), - epoch: None, - state: SubscriberOwnerState::Staged, - baseline: None, - progress: None, - progress_stream_revision: None, - }); - let next_registered = aggregate_interests(&self.base_interests, &next_owned); - validate_supported_interests(self.mode, &self.config, &next_registered)?; + async fn next(&mut self) -> Option> { + poll_fn(|cx| { + self.normalize_next_index(); + if self.entries.is_empty() { + return std::task::Poll::Ready(None); + } - let baseline = match start { - SubscriberOwnerStart::Live => None, - SubscriberOwnerStart::PostBlock(block) => { - block - .number - .checked_add(1) - .ok_or(SubscriberOwnerError::PostBlockOverflow(block.number))?; - Some(block) + let mut index = self.next_index; + let mut checked = 0usize; + while checked < self.entries.len() { + if index >= self.entries.len() { + index = 0; + } + match self.entries[index].stream.as_mut().poll_next(cx) { + std::task::Poll::Ready(Some(event)) => { + if matches!(event, SubscriberEvent::StreamTerminated(_)) { + self.entries.remove(index); + self.next_index = if self.entries.is_empty() { + 0 + } else { + index % self.entries.len() + }; + } else { + self.next_index = (index + 1) % self.entries.len(); + } + return std::task::Poll::Ready(Some(event)); + } + std::task::Poll::Ready(None) => { + self.entries.remove(index); + if self.entries.is_empty() { + self.next_index = 0; + return std::task::Poll::Ready(None); + } + } + std::task::Poll::Pending => { + checked += 1; + index += 1; + } + } } - }; - let sequence = self - .next_owner_epoch - .checked_add(1) - .ok_or(SubscriberOwnerError::EpochExhausted)?; - let epoch = SubscriberOwnerEpoch { - owner: owner.clone(), - sequence, - }; - self.next_owner_epoch = sequence; - let entry = next_owned - .last_mut() - .expect("staged replacement owner was appended during preflight"); - entry.epoch = Some(epoch.clone()); - entry.baseline = baseline; - self.owned_interests = next_owned; - self.interests = next_registered; - self.sources_dirty = true; - Ok(epoch) + if self.entries.is_empty() { + std::task::Poll::Ready(None) + } else { + self.next_index = index % self.entries.len(); + std::task::Poll::Pending + } + }) + .await } +} - /// Current transaction state for an exact owner epoch. - pub fn interest_owner_state( - &self, - epoch: &SubscriberOwnerEpoch, - ) -> Option { - self.owned_interests - .iter() - .find(|entry| entry.epoch.as_ref() == Some(epoch)) - .map(|entry| entry.state) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[allow(dead_code)] +enum SubscriberTransport { + PubSub, + Polling, +} + +#[derive(Clone, Debug)] +enum SubscriberStreamSource { + PubSubLog { id: usize, filter: Filter }, + BasePendingLog { id: usize, filter: Filter }, + BaseFlashblocks, + OpPendingFlashblocks, + PubSubPendingHashes, + PubSubBlockHeaders, + PollingLog { filter: Filter }, + PollingPendingHashes, +} + +impl SubscriberStreamSource { + fn label(&self) -> &'static str { + match self { + Self::PubSubLog { .. } => "pubsub log", + Self::BasePendingLog { .. } => "Base pendingLogs", + Self::BaseFlashblocks => "Base newFlashblocks", + Self::OpPendingFlashblocks => "OP pending Flashblocks", + Self::PubSubPendingHashes => "pubsub pending transaction hash", + Self::PubSubBlockHeaders => "pubsub block header", + Self::PollingLog { .. } => "polling log", + Self::PollingPendingHashes => "polling pending transaction hash", + } } - /// Latest hash-certified reconcile progress for an exact owner epoch. - pub fn interest_owner_progress( - &self, - epoch: &SubscriberOwnerEpoch, - ) -> Option<&SubscriberOwnerProgress> { - self.owned_interests - .iter() - .find(|entry| entry.epoch.as_ref() == Some(epoch)) - .and_then(|entry| entry.progress.as_ref()) + fn is_pubsub(&self) -> bool { + matches!( + self, + Self::PubSubLog { .. } + | Self::BasePendingLog { .. } + | Self::BaseFlashblocks + | Self::PubSubPendingHashes + | Self::PubSubBlockHeaders + ) } - /// Make a staged owner canonical after its actor-side installation commits. - /// - /// Returns `false` for stale tokens and owners not currently staged. - pub fn activate_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool { - let stream_revision = self.stream_revision; - let sources_dirty = self.sources_dirty; - let Some(entry) = self - .owned_interests - .iter_mut() - .find(|entry| entry.epoch.as_ref() == Some(epoch)) - else { - return false; + fn is_flashblocks(&self) -> bool { + matches!( + self, + Self::BasePendingLog { .. } | Self::BaseFlashblocks | Self::OpPendingFlashblocks + ) + } + + fn same_key(&self, other: &Self) -> bool { + match (self, other) { + (Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. }) + | ( + Self::BasePendingLog { filter: left, .. }, + Self::BasePendingLog { filter: right, .. }, + ) + | (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => { + left == right + } + (Self::BaseFlashblocks, Self::BaseFlashblocks) + | (Self::OpPendingFlashblocks, Self::OpPendingFlashblocks) + | (Self::PubSubPendingHashes, Self::PubSubPendingHashes) + | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders) + | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true, + _ => false, + } + } +} + +#[allow(dead_code)] +enum SubscriberEvent { + Log { + source_id: usize, + log: Log, + }, + BackfilledLogs { + source_id: usize, + logs: Vec, + }, + Logs(Vec), + BlockHeader(N::HeaderResponse), + PendingHash(B256), + PendingHashes(Vec), + BasePendingLog { + source_id: usize, + log: Log, + }, + BaseFlashblock(BaseFlashblockWirePayload), + OpFlashblockTick, + PreconfirmedLogs { + flashblock: FlashblockRef, + logs: Vec, + }, + FlashblockObserved, + StreamTerminated(SubscriberStreamSource), +} + +impl EventSubscriber for AlloySubscriber +where + P: Provider + Send + Sync, + N: Network + 'static, + N::HeaderResponse: Send + 'static, +{ + fn chain_id(&self) -> Option { + self.chain_id + } + + fn capabilities(&self) -> SubscriberCapabilities { + let Ok(transport) = resolve_subscriber_transport(self.mode) else { + return SubscriberCapabilities::default(); }; - if entry.state != SubscriberOwnerState::Staged - || (entry.baseline.is_some() - && (entry.progress.is_none() - || entry.progress_stream_revision != Some(stream_revision) - || sources_dirty)) + let mut capabilities = vec![ + SubscriberCapability::Logs, + SubscriberCapability::PendingTransactionHashes, + SubscriberCapability::HistoricalBackfill, + SubscriberCapability::Live, + SubscriberCapability::OwnerScopedDelivery, + SubscriberCapability::DynamicInterests, + ]; + if transport == SubscriberTransport::PubSub { + capabilities.push(SubscriberCapability::BlockHeaders); + } + if self.config.preconfirmations != PreconfirmationMode::Disabled + && self.provider_ref.is_some() + && self.chain_id.and_then(flashblocks_adapter).is_some() { - return false; + capabilities.push(SubscriberCapability::Preconfirmations); } - entry.state = SubscriberOwnerState::Active; - true + SubscriberCapabilities::new(capabilities) } - /// Atomically replace one active owner epoch with one reconciled staged epoch. - pub fn commit_interest_owner_replacement( + fn register_interests( &mut self, - active: &SubscriberOwnerEpoch, - replacement: &SubscriberOwnerEpoch, - ) -> bool { - let Some(active_index) = self - .owned_interests - .iter() - .position(|entry| entry.epoch.as_ref() == Some(active)) - else { - return false; - }; - let Some(replacement_index) = self - .owned_interests - .iter() - .position(|entry| entry.epoch.as_ref() == Some(replacement)) - else { - return false; + interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + validate_subscriber_config(&self.config)?; + validate_supported_interests(self.mode, &self.config, &interests)?; + if !interests.is_empty() { + self.ensure_chain_id().await?; + } + self.validate_flashblocks_setup()?; + + self.base_interests = interests; + self.owned_interests.clear(); + self.rebuild_registered_interests(); + self.reset_delivery_state(); + self.state = AlloySubscriberState::Uninitialized; + Ok(()) + }) + } + + fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> { + Box::pin(async { + Ok(self + .next_scoped_batch() + .await? + .map(SubscriberInputBatch::into_reactive_batch)) + }) + } +} + +impl AlloySubscriber +where + P: Provider + Send + Sync, + N: Network + 'static, + N::HeaderResponse: Send + 'static, +{ + /// Resolve the provider's chain identity once. The assignment happens only + /// after a complete RPC response, so cancelling the future leaves the + /// subscriber cleanly retryable. + async fn ensure_chain_id(&mut self) -> Result { + if let Some(chain_id) = self.chain_id { + return Ok(chain_id); + } + let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?; + self.chain_id = Some(chain_id); + Ok(chain_id) + } + + fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> { + if self.config.preconfirmations == PreconfirmationMode::Disabled { + return Ok(()); + } + if self.provider_ref.is_none() { + return Err(SubscriberError::InvalidConfig( + "Flashblocks require a stable provider ref from a pinned provider lease", + )); + } + let Some(chain_id) = self.chain_id else { + return Ok(()); }; - if active_index == replacement_index - || active.owner() != replacement.owner() - || self.owned_interests[active_index].state != SubscriberOwnerState::Active - || self.owned_interests[replacement_index].state != SubscriberOwnerState::Staged - || (self.owned_interests[replacement_index].baseline.is_some() - && (self.owned_interests[replacement_index].progress.is_none() - || self.owned_interests[replacement_index].progress_stream_revision - != Some(self.stream_revision) - || self.sources_dirty)) - { - return false; + match flashblocks_adapter(chain_id) { + Some(FlashblocksAdapter::BaseNative) + if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub + && self.config.preconfirmations == PreconfirmationMode::Required => + { + return Err(SubscriberError::Unsupported( + "Base Flashblocks require pubsub for newFlashblocks and pendingLogs", + )); + } + Some(_) => {} + None if self.config.preconfirmations == PreconfirmationMode::Required => { + return Err(SubscriberError::Unsupported( + "Flashblocks are currently implemented for Base and OP chains", + )); + } + None => {} } + Ok(()) + } - self.owned_interests[replacement_index].state = SubscriberOwnerState::Active; - self.owned_interests.remove(active_index); - self.purge_owner_epoch(active); - self.rebuild_registered_interests(); - self.retire_unreferenced_filters(); - self.sources_dirty = true; - true + /// Subscribe first, then catch an exact staged owner up through a verified + /// canonical block. + /// + /// This compatibility wrapper delegates to + /// [`reconcile_interest_owners`](Self::reconcile_interest_owners), so a + /// driver adopting several owners should call the bulk API once rather than + /// invoking this method in a loop. + /// + /// # Errors + /// + /// Returns [`SubscriberOwnerError`] when the epoch is not staged, lacks a + /// baseline, conflicts/regresses, provider certification or transport + /// fails, returned logs are invalid, or subscriber resources are exhausted. + pub async fn reconcile_interest_owner( + &mut self, + epoch: &SubscriberOwnerEpoch, + through: BlockRef, + ) -> Result + where + P: Clone, + { + self.reconcile_interest_owners(std::slice::from_ref(epoch), through) + .await? + .pop() + .ok_or(SubscriberOwnerError::NotStaged) } - /// Prepare an exact active owner for removal without changing desired - /// interests, streams, anchors, or queued canonical input. + /// Subscribe first, then atomically catch staged owners up through one + /// verified canonical block. /// - /// The caller establishes its delivery fence after this transition. Use - /// [`abort_interest_owner`](Self::abort_interest_owner) to restore the owner - /// on actor-side failure, or - /// [`finalize_interest_owner_removal`](Self::finalize_interest_owner_removal) - /// once canonical routing has been removed. - pub fn prepare_interest_owner_removal(&mut self, epoch: &SubscriberOwnerEpoch) -> bool { - let Some(entry) = self - .owned_interests - .iter_mut() - .find(|entry| entry.epoch.as_ref() == Some(epoch)) - else { - return false; - }; - if entry.state != SubscriberOwnerState::Active { - return false; + /// All epochs are preflighted before provider I/O. Live streams are + /// reconciled once, compatible provider filters are merged into bounded + /// chunks, and every historical request shares one double target-header + /// certification. Provider-filter supersets are routed back through each + /// owner's exact interests, retaining owner-scoped delivery provenance. + /// Duplicate epoch tokens in `epochs` are coalesced in first-seen order. + /// + /// Live events are continuously drained while an independent provider + /// clone performs catch-up. Fetched owner records and progress become + /// visible only after every request and the final certification succeed. A + /// failure leaves every target staged with its prior progress unchanged; + /// live canonical delivery consumed during the attempt is preserved while + /// excluding the failed target epochs from its staged-owner audience. + /// + /// # Errors + /// + /// Returns [`SubscriberOwnerError`] when an epoch is not staged, lacks a + /// baseline, conflicts/regresses, provider certification or transport + /// fails, returned logs are invalid, or subscriber resources are exhausted. + /// Target progress remains unchanged on error. + pub async fn reconcile_interest_owners( + &mut self, + epochs: &[SubscriberOwnerEpoch], + through: BlockRef, + ) -> Result, SubscriberOwnerError> + where + P: Clone, + { + if epochs.is_empty() { + return Ok(Vec::new()); + } + + self.ensure_chain_id().await?; + + let mut seen = HashSet::new(); + let mut plans = Vec::with_capacity(epochs.len()); + for epoch in epochs { + if !seen.insert(epoch.clone()) { + continue; + } + let entry = self + .owned_interests + .iter() + .find(|entry| { + entry.epoch.as_ref() == Some(epoch) + && entry.state == SubscriberOwnerState::Staged + }) + .ok_or(SubscriberOwnerError::NotStaged)?; + let position = entry + .progress + .as_ref() + .map(|progress| &progress.through) + .or(entry.baseline.as_ref()) + .ok_or(SubscriberOwnerError::MissingBaseline)?; + let baseline = position.number; + if through.number < baseline { + return Err(SubscriberOwnerError::ProgressRegression { + current: baseline, + target: through.number, + }); + } + let from_block = baseline + .checked_add(1) + .ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?; + if through.number == baseline && through.hash != position.hash { + return Err(SubscriberOwnerError::ProgressConflict { + number: baseline, + current_hash: position.hash, + target_hash: through.hash, + }); + } + if through.number == from_block + && through + .parent_hash + .is_some_and(|parent| parent != position.hash) + { + return Err(SubscriberOwnerError::ProgressConflict { + number: baseline, + current_hash: position.hash, + target_hash: through.parent_hash.expect("checked as present above"), + }); + } + if entry + .interests + .iter() + .any(|interest| !matches!(interest, ReactiveInterest::Logs(_))) + { + return Err(SubscriberOwnerError::UnsupportedPostBlockInterest); + } + plans.push(SubscriberOwnerReconcilePlan { + epoch: epoch.clone(), + interests: entry.interests.clone(), + retained: *position, + from_block, + }); + } + + // The ordering is intentional and part of the public continuity + // contract: connect first, then fetch the bounded historical window. + self.ensure_streams().await?; + let provider = self.provider.clone(); + let filters = merged_owner_reconcile_filters(&plans, through.number); + let retained = plans.iter().map(|plan| plan.retained).collect(); + let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect(); + let fetch = fetch_owner_catchup::( + provider, + filters, + retained, + through, + SubscriberOwnerCatchupOptions { + target_preverified: false, + max_logs: self.config.max_pending_records, + max_log_bytes: self.config.max_backfill_log_bytes, + max_requests_in_flight: self.config.max_reconcile_requests_in_flight, + }, + ); + let SubscriberOwnerCatchup { logs, certified } = + self.drive_reconcile_fetch(fetch, &target_epochs).await?; + + let records = logs + .into_iter() + .map(|log| log_input_record(log, InputSource::Backfill)) + .collect(); + let mut routed_records = Vec::new(); + for record in dedupe_records(sort_records(records)).map_err(|error| { + SubscriberError::InvalidBackfill(format!( + "conflicting duplicate owner catch-up record: {error}" + )) + })? { + let block_number = match &record.input { + ReactiveInput::Log(log) => log + .block_number + .expect("bulk catch-up logs were validated before commit"), + _ => unreachable!("bulk owner catch-up contains log records only"), + }; + let owners: Vec = plans + .iter() + .filter(|plan| block_number >= plan.from_block) + .filter(|plan| { + plan.interests + .iter() + .any(|interest| interest_matches(interest, &record.input)) + }) + .map(|plan| plan.epoch.clone()) + .collect(); + if !owners.is_empty() { + routed_records.push((record, owners)); + } + } + self.ensure_pending_record_capacity( + routed_records.len(), + "owner reconciliation historical records", + )?; + + // Nothing provider-derived becomes authoritative until every record is + // known to fit. In particular, preserve queued retry state and owner + // progress when the bounded delivery queue cannot accept the catch-up. + self.pending_backfills.retain(|queued| { + queued + .epoch + .as_ref() + .is_none_or(|epoch| !target_epochs.contains(epoch)) + }); + for (record, owners) in routed_records { + self.enqueue_owner_record_for_owners_unmerged(record, owners); + } + self.promote_reconcile_owner_records(&target_epochs); + self.seed_reconciled_filter_anchors(&plans, certified.number); + + let stream_revision = self.stream_revision; + let mut progress = Vec::with_capacity(plans.len()); + for plan in plans { + let item = SubscriberOwnerProgress { + owner: plan.epoch.clone(), + through: certified, + }; + let entry = self + .owned_interests + .iter_mut() + .find(|entry| entry.epoch.as_ref() == Some(&plan.epoch)) + .expect("bulk reconcile holds exclusive access after epoch preflight"); + entry.progress = Some(item.clone()); + entry.progress_stream_revision = Some(stream_revision); + progress.push(item); } - entry.state = SubscriberOwnerState::Removing; - true + Ok(progress) } - /// Finalize a previously prepared exact owner removal. - /// - /// Returns the removed interests, or `None` for stale tokens and owners not - /// currently in [`SubscriberOwnerState::Removing`]. Repeating finalization - /// is therefore idempotent. - pub fn finalize_interest_owner_removal( + async fn drive_reconcile_fetch( &mut self, - epoch: &SubscriberOwnerEpoch, - ) -> Option>> { - let index = self.owned_interests.iter().position(|entry| { - entry.epoch.as_ref() == Some(epoch) && entry.state == SubscriberOwnerState::Removing - })?; - let removed = self.owned_interests.remove(index).interests; - self.purge_owner_epoch(epoch); - self.rebuild_registered_interests(); - self.retire_unreferenced_filters(); - self.sources_dirty = true; - Some(removed) - } - - /// Abort an epoch-scoped owner lifecycle operation. - /// - /// A staged owner is removed completely. A prepared removal is restored to - /// active. Active and unknown epochs are unchanged. Repeating the same - /// abort is therefore safe and returns `false` after the first effect. - pub fn abort_interest_owner(&mut self, epoch: &SubscriberOwnerEpoch) -> bool { - let Some(index) = self - .owned_interests - .iter() - .position(|entry| entry.epoch.as_ref() == Some(epoch)) - else { - return false; - }; - match self.owned_interests[index].state { - SubscriberOwnerState::Staged => { - self.owned_interests.remove(index); - self.purge_owner_epoch(epoch); - self.rebuild_registered_interests(); - self.retire_unreferenced_filters(); - self.sources_dirty = true; - true - } - SubscriberOwnerState::Removing => { - self.owned_interests[index].state = SubscriberOwnerState::Active; - true - } - SubscriberOwnerState::Active => false, + fetch: F, + target_epochs: &HashSet, + ) -> Result + where + F: Future>, + { + if !matches!(&self.state, AlloySubscriberState::Active(_)) { + return fetch.await; } - } - - fn purge_owner_epoch(&mut self, epoch: &SubscriberOwnerEpoch) { - self.pending_backfills - .retain(|backfill| backfill.epoch.as_ref() != Some(epoch)); - self.pending_records - .retain_mut(|pending| match &mut pending.scope { - SubscriberInputScope::Canonical { owners } => { - owners.retain(|owner| owner != epoch); - true - } - SubscriberInputScope::OwnerOnly { owners } => { - owners.retain(|owner| owner != epoch); - !owners.is_empty() + let mut fetch = Box::pin(fetch); + loop { + let event = { + let live = Box::pin(self.next_event()); + match select(fetch, live).await { + Either::Left((result, pending_live)) => { + drop(pending_live); + return result; + } + Either::Right((event, pending_fetch)) => { + fetch = pending_fetch; + event + } } - }); - self.pending_reconcile_owner_records.retain_mut(|pending| { - pending.owners.retain(|owner| owner != epoch); - !pending.owners.is_empty() - }); - self.recent_owner_input_refs.remove(epoch); - self.recent_owner_input_ref_sets.remove(epoch); + }; + let event = event?.ok_or_else(|| { + SubscriberError::Provider( + "Alloy subscriber streams ended during owner reconcile".to_owned(), + ) + })?; + self.buffer_reconcile_event_for_owners(&event, target_epochs); + self.enqueue_event_excluding_owners(event, target_epochs); + self.check_resource_error()?; + } } - /// Add or replace the interests owned by `owner`. + /// Poll one driver control future with priority over the next scoped batch. /// - /// This preserves unrelated owners, queued/pending records, recent dedupe - /// state, and last-seen log anchors. The live transport is reconciled on the - /// next [`EventSubscriber::next_batch`] call so newly added log filters can - /// be subscribed without rebuilding the whole subscriber object. + /// This is the supported control-interleaving primitive for a subscriber + /// driver. `control` is borrowed rather than consumed, so a batch win leaves + /// the caller's pending control future alive. When control wins, the + /// in-progress subscriber poll is cancelled at a documented safe boundary: + /// queued records are removed only when a complete batch is returned, + /// successful backfill steps are committed before the next await, provider + /// streams created but not installed are dropped, and installed streams + /// remain owned by the subscriber for the next call. /// - /// Replacing an existing owner is continuity-safe: filters the owner - /// already had keep their delivery anchors, and any changed or new filter - /// shape is automatically backfilled from the owner's oldest prior anchor — - /// growing a pool set on an established owner does not open a delivery gap - /// for what the old subscription had already covered. A brand-new owner has - /// no anchor to inherit; pass an explicit - /// [`add_interest_owner_with_backfill`](Self::add_interest_owner_with_backfill) - /// anchor (or register through [`ReactiveEngine::register_handler`], which - /// anchors to the runtime's last canonical block). - pub fn add_interest_owner( + /// The control future is polled first. Therefore a ready shutdown/removal + /// command cannot starve behind a continuously ready subscriber queue. + /// + /// # Errors + /// + /// Returns [`SubscriberError`] when the subscriber poll encounters a + /// transport, continuity, decoding, configuration, or resource failure. + pub async fn next_scoped_batch_or( &mut self, - owner: HandlerId, - interests: &[ReactiveInterest], - ) -> Result<(), SubscriberError> { - self.set_interest_owner(owner, interests, None) + control: Pin<&mut F>, + ) -> Result, SubscriberError> + where + C: Send, + F: Future + Send, + { + let batch = self.next_scoped_batch(); + match select(control, batch).await { + Either::Left((control, pending_batch)) => { + drop(pending_batch); + Ok(SubscriberDriverPoll::Control(control)) + } + Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch), + } } - /// Add or replace owner interests and schedule log backfill for that owner. + /// Return the next subscriber batch while retaining staged-owner delivery + /// provenance captured at enqueue time. /// - /// Backfill is queued only for log interests; block and pending transaction - /// interests are live-only. Queued records can be delivered immediately; - /// the subsequent provider stream is then caught up from the seeded - /// delivery anchor, and overlap is deduplicated — so the discovery boundary - /// is closed end to end as long - /// as `backfill` starts at (or before) the block the interest was - /// discovered in. Continuity backfill for a replaced owner (see - /// [`add_interest_owner`](Self::add_interest_owner)) is queued in addition, - /// unless this explicit backfill is open-ended and already starts at or - /// below the owner's prior anchor. - pub fn add_interest_owner_with_backfill( - &mut self, - owner: HandlerId, - interests: &[ReactiveInterest], - backfill: SubscriberBackfill, - ) -> Result<(), SubscriberError> { - self.set_interest_owner(owner, interests, Some(backfill)) + /// Transaction-aware drivers must use this method. The compatibility + /// [`EventSubscriber::next_batch`] method flattens the same queue and keeps + /// its historical behavior for existing callers. + /// + /// For command interleaving, prefer + /// [`next_scoped_batch_or`](Self::next_scoped_batch_or), which preserves the + /// cancellation-safety invariants of this poll and prioritizes ready control. + pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> { + Box::pin(async { + self.check_resource_error()?; + if self.chain_id.is_none() + && (!self.pending_records.is_empty() + || !self.pending_chain_controls.is_empty() + || !self.pending_backfills.is_empty() + || !self.interests.is_empty()) + { + self.ensure_chain_id().await?; + } + if let Some(batch) = self.drain_next_scoped_batch() { + return Ok(Some(batch)); + } + + // Subscribe/adopt the complete desired topology before resolving + // any queued historical upper bound. Live streams therefore own + // every event that can arrive while the bounded backfill is in + // flight, including the coordinated registration window. + self.ensure_streams().await?; + self.check_resource_error()?; + if let Some(batch) = self.drain_next_scoped_batch() { + return Ok(Some(batch)); + } + + self.drain_pending_backfills().await?; + self.check_resource_error()?; + if let Some(batch) = self.drain_next_scoped_batch() { + return Ok(Some(batch)); + } + + if self.interests.is_empty() { + return Ok(None); + } + + loop { + let Some(event) = self.next_event().await? else { + return Ok(None); + }; + + self.enqueue_event(event); + self.check_resource_error()?; + if let Some(batch) = self.drain_next_scoped_batch() { + return Ok(Some(batch)); + } + } + }) } - /// Remove one owner's interests, preserving unrelated owner/base interests. + /// Bring live streams in line with the current interest set. /// - /// The owner's queued backfills are dropped, and source-id/anchor - /// bookkeeping for filters no other owner references is retired. Live - /// streams for retired filters are torn down on the next - /// [`EventSubscriber::next_batch`] call (dropping an Alloy subscription - /// unsubscribes provider-side); events already in flight from them stop - /// matching the merged interest set and are discarded. - pub fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option>> { - let index = self - .owned_interests - .iter() - .position(|entry| &entry.owner == owner)?; - let removed = self.owned_interests.remove(index); - if let Some(epoch) = &removed.epoch { - self.purge_owner_epoch(epoch); - } else { - self.pending_backfills - .retain(|backfill| &backfill.owner != owner); + /// Runs incrementally: the desired-vs-live diff only happens when interest + /// bookkeeping changed since the last successful pass (`sources_dirty`), so + /// steady-state polling costs nothing here. Missing sources are connected, + /// sources for retired filters are dropped (dropping an Alloy subscription + /// unsubscribes provider-side), and unrelated live streams — with their + /// delivery and anchor state — are left untouched. + /// + /// A newly connected log source whose filter already has a delivery anchor + /// is caught up from that anchor immediately after subscribing (the same + /// subscribe-then-backfill order the reconnect path uses). Together with + /// anchor seeding in [`Self::drain_pending_backfills`], that closes the + /// window between an adoption backfill and live stream start. + async fn ensure_streams(&mut self) -> Result<(), SubscriberError> { + if !self.sources_dirty { + return Ok(()); + } + // An interest-less subscriber never touches the provider + // ([`EventSubscriber::next_batch`] returns `Ok(None)`). Still certify + // the empty desired topology as clean so a deliberately empty staged + // epoch can reconcile and activate instead of remaining dirty forever. + if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() { + self.bump_stream_revision(); + self.sources_dirty = false; + return Ok(()); } - self.rebuild_registered_interests(); - self.retire_unreferenced_filters(); - self.sources_dirty = true; - Some(removed.interests) - } - - /// Borrow the interests currently owned by `owner`. - pub fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest]> { - self.owned_interests - .iter() - .find(|entry| &entry.owner == owner) - .map(|entry| entry.interests.as_slice()) - } - - fn set_interest_owner( - &mut self, - owner: HandlerId, - interests: &[ReactiveInterest], - backfill: Option, - ) -> Result<(), SubscriberError> { - validate_subscriber_config(&self.config)?; - let mut next_owned = self.clone_owned_interests(); - let replaced_epoch = match next_owned.iter_mut().find(|entry| entry.owner == owner) { - Some(entry) => { - entry.interests = interests.to_vec(); - entry.state = SubscriberOwnerState::Active; - entry.baseline = None; - entry.progress = None; - entry.progress_stream_revision = None; - entry.epoch.take() - } - None => { - next_owned.push(OwnedSubscriberInterests { - owner: owner.clone(), - interests: interests.to_vec(), - epoch: None, - state: SubscriberOwnerState::Active, - baseline: None, - progress: None, - progress_stream_revision: None, - }); - None - } + let desired = self.stream_sources()?; + let missing: Vec = match &self.state { + AlloySubscriberState::Active(streams) => desired + .iter() + .filter(|source| !streams.contains_source(source)) + .cloned() + .collect(), + AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(), }; - let next_registered = aggregate_interests(&self.base_interests, &next_owned); - validate_supported_interests(self.mode, &self.config, &next_registered)?; - - // Continuity capture, before the mutation lands: the owner's previous - // filter shapes and the oldest delivery anchor among them. A changed - // filter gets a fresh source id with no anchor, so without this - // hand-off, replacing an owner's interests (the normal way to grow a - // pool set) would silently discard the delivery watermark and open a - // gap until some later explicit backfill. - let previous_filters: Vec = self - .owner_interests(&owner) - .map(log_filters) - .unwrap_or_default(); - let continuity_anchor: Option = previous_filters - .iter() - .filter_map(|filter| self.log_anchor(filter)) - .min(); - self.owned_interests = next_owned; - self.interests = next_registered; - if let Some(epoch) = replaced_epoch { - self.purge_owner_epoch(&epoch); + for source in missing { + let stream = self.connect_source_stream(source.clone()).await?; + // Publish each successful connection before any later await. If a + // second connection or anchored catch-up fails/cancels, this stream + // remains live and the next reconcile skips reconnecting it. + self.install_source_stream(source.clone(), stream); + if self.source_requires_backfill(&source) { + self.queue_source_backfill(source); + } } - self.retire_unreferenced_filters(); - self.sources_dirty = true; - // Re-queue this owner's backfills from scratch: previously queued - // entries may reference filter shapes that no longer exist. - self.pending_backfills - .retain(|queued| queued.owner != owner); - for filter in log_filters(interests) { - if let Some(backfill) = backfill { - self.pending_backfills.push_back(QueuedSubscriberBackfill { - owner: owner.clone(), - epoch: None, - filter: filter.clone(), - backfill, - }); + while let Some(source) = self.pending_source_backfills.front().cloned() { + let desired_and_live = desired.iter().any(|item| item.same_key(&source)) + && matches!( + &self.state, + AlloySubscriberState::Active(streams) if streams.contains_source(&source) + ); + if !desired_and_live { + self.pending_source_backfills.pop_front(); + continue; } - // Continuity backfill for changed/new shapes only: an unchanged - // filter kept its anchor and its live stream, and an open-ended - // explicit backfill starting at or below the anchor already covers - // the window. - let unchanged = previous_filters.contains(&filter); - let explicit_covers = backfill.is_some_and(|explicit| { - explicit.end_block().is_none() - && continuity_anchor.is_some_and(|anchor| explicit.start_block() <= anchor) - }); - if let Some(anchor) = continuity_anchor - && !unchanged - && !explicit_covers - { - self.pending_backfills.push_back(QueuedSubscriberBackfill { - owner: owner.clone(), - epoch: None, - filter, - backfill: SubscriberBackfill::from_block(anchor), - }); + // Anchored catch-up for a source with a known delivery watermark + // (seeded by a drained adoption backfill, or inherited from a + // filter shape that was live before): subscribe first, then fetch + // the gap, so nothing lands between the two. Pop only after the + // request succeeds; errors and cancellation retain retry intent. + let event = self.backfill_reconnected_source(&source).await?; + self.pending_source_backfills.pop_front(); + if let Some(event) = event { + self.enqueue_event(event); } } - Ok(()) - } - fn clone_owned_interests(&self) -> Vec> { - self.owned_interests - .iter() - .map(|entry| OwnedSubscriberInterests { - owner: entry.owner.clone(), - interests: entry.interests.clone(), - epoch: entry.epoch.clone(), - state: entry.state, - baseline: entry.baseline.clone(), - progress: entry.progress.clone(), - progress_stream_revision: entry.progress_stream_revision, - }) - .collect() - } + if let AlloySubscriberState::Active(streams) = &mut self.state { + streams.retain_sources(&desired); + if streams.is_empty() { + self.state = AlloySubscriberState::Empty; + } + } - fn rebuild_registered_interests(&mut self) { - self.interests = aggregate_interests(&self.base_interests, &self.owned_interests); + self.bump_stream_revision(); + self.sources_dirty = false; + self.retire_unreferenced_filters(); + Ok(()) } - /// Delivery anchor (last block known fully delivered) for `filter`, if the - /// filter has a source id and has seen delivery. - fn log_anchor(&self, filter: &Filter) -> Option { - if let Some(anchor) = self - .log_source_ids - .get(filter) - .and_then(|id| self.last_seen_log_blocks.get(id)) - { - return Some(*anchor); + fn install_source_stream( + &mut self, + source: SubscriberStreamSource, + stream: BoxStream<'static, SubscriberEvent>, + ) { + match &mut self.state { + AlloySubscriberState::Active(streams) => { + if streams.contains_source(&source) { + return; + } + streams.push(source, stream); + } + AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => { + let mut streams = SubscriberStreams::new(); + streams.push(source, stream); + self.state = AlloySubscriberState::Active(streams); + } } - - // Logical owner filters may be represented by a broader provider - // stream after fan-in. Its oldest live watermark is a conservative - // continuity anchor: it can cause extra backfill, never a missed log. - self.log_source_ids - .values() - .filter_map(|id| self.last_seen_log_blocks.get(id).copied()) - .min() + // A partially completed reconcile is still a topology change. Advance + // the revision now rather than only at the final clean boundary. + self.bump_stream_revision(); } - /// Every logical log filter across base and owner interests, merged within - /// each origin and deduplicated across origins. These shapes remain the - /// exact routing and owner-continuity boundary; provider subscriptions may - /// fan several of them into one broader filter. - // `Filter` derives `Hash`/`Eq` and has no interior mutability; the - // `mutable_key_type` lint is a known false positive for it. - #[allow(clippy::mutable_key_type)] - fn logical_log_filters(&self) -> Vec { - let mut filters = log_filters(&self.base_interests); - for entry in &self.owned_interests { - filters.extend(log_filters(&entry.interests)); - } - let mut seen = HashSet::new(); - filters.retain(|filter| seen.insert(filter.clone())); - filters + fn source_requires_backfill(&self, source: &SubscriberStreamSource) -> bool { + matches!(source, SubscriberStreamSource::PubSubLog { id, .. } + if self.last_seen_log_blocks.contains_key(id)) } - /// Provider-facing log filters. Compatible logical filters fan into a - /// small number of address/topic supersets, then split only when the - /// configured address ceiling requires it. Exact matching remains local in - /// `enqueue_event`, so this reduces subscriptions without broadening owner - /// delivery. - fn log_stream_filters(&self) -> Vec { - let mut merged = Vec::new(); - for filter in self.logical_log_filters() { - merge_log_subscription_filter(&mut merged, &filter); + fn queue_source_backfill(&mut self, source: SubscriberStreamSource) { + if !self + .pending_source_backfills + .iter() + .any(|pending| pending.same_key(&source)) + { + self.pending_source_backfills.push_back(source); } + } - let max_addresses = self.config.max_log_addresses_per_subscription.max(1); - let mut planned = Vec::new(); - for filter in merged { - let mut addresses: Vec<_> = filter.address.iter().copied().collect(); - if addresses.len() <= max_addresses { - planned.push(filter); + /// Fetch queued adoption/continuity backfills, oldest first. + /// + /// An entry is consumed only after its `get_logs` fetch succeeds — a + /// transient RPC failure surfaces the error and leaves the entry queued for + /// the next poll, so a flaky request cannot silently discard the missed + /// window the backfill exists to close. Open-ended backfills resolve their + /// upper bound to the provider's current head before fetching, and every + /// drained backfill advances the filter's delivery anchor to that bound — + /// even a zero-log window — so the filter is reconnect-protected from then + /// on. Draining pauses as soon as records are ready for delivery; remaining + /// entries stay queued. + async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> { + while let Some(queued) = self.pending_backfills.front() { + // Owner was removed while its backfill was queued. + let epoch = queued.epoch.clone(); + let owner = queued.owner.clone(); + let owner_exists = match (&epoch, &owner) { + (Some(epoch), _) => self.interest_owner_state(epoch).is_some(), + (None, Some(owner)) => self.owner_interests(owner).is_some(), + (None, None) => true, + }; + if !owner_exists { + self.pending_backfills.pop_front(); continue; } - addresses.sort_unstable(); - for chunk in addresses.chunks(max_addresses) { - let mut split = filter.clone(); - split.address = FilterSet::default(); - for address in chunk { - split.address.insert(*address); - } - planned.push(split); - } - } - planned - } + let filters = queued.filters.clone(); + let backfill = queued.backfill; - /// Drop source-id and anchor bookkeeping for filters no longer referenced - /// by any base or owner interest, so long-lived owner churn cannot grow the - /// maps unboundedly. Live streams for retired filters are pruned by the - /// next reconcile. - // `Filter` derives `Hash`/`Eq` and has no interior mutability; the - // `mutable_key_type` lint is a known false positive for it. - #[allow(clippy::mutable_key_type)] - fn retire_unreferenced_filters(&mut self) { - let mut live: HashSet = self.log_stream_filters().into_iter().collect(); - if let AlloySubscriberState::Active(streams) = &self.state { - for entry in &streams.entries { - match &entry.source { - SubscriberStreamSource::PubSubLog { filter, .. } - | SubscriberStreamSource::PollingLog { filter } => { - live.insert(filter.clone()); + let to_block = match backfill.end_block() { + Some(to_block) => to_block, + None => self + .provider + .get_block_number() + .await + .map_err(provider_error)?, + }; + if to_block < backfill.start_block() { + // An exclusive post-baseline range can be empty when the + // provider is still exactly at the retained head. Consume the + // work only after validating that head and seed the filter at + // the proven baseline so reconnect catch-up starts at C + 1. + let certified = if let Some(retained) = backfill.retained_anchor() { + let actual = + fetch_provider_block_ref::(&self.provider, retained.number).await?; + if !block_ref_satisfies_expected(&actual, retained) { + return Err(SubscriberError::InvalidBackfill(format!( + "retained anchor {}:{:?} conflicts with provider block {}:{:?}", + retained.number, retained.hash, actual.number, actual.hash + ))); } - SubscriberStreamSource::PubSubPendingHashes - | SubscriberStreamSource::PubSubBlockHeaders - | SubscriberStreamSource::PollingPendingHashes => {} + if to_block < retained.number { + return Err(SubscriberError::InvalidBackfill(format!( + "backfill upper bound {to_block} precedes retained anchor {}", + retained.number + ))); + } + Some(actual) + } else { + None + }; + self.pending_backfills.pop_front(); + for filter in &filters { + let source_id = self.log_source_id(filter); + if let Some(certified) = certified { + self.last_seen_log_blocks + .entry(source_id) + .and_modify(|anchor| *anchor = (*anchor).max(certified.number)) + .or_insert(certified.number); + } + } + if owner.is_none() + && let Some(certified) = certified + { + self.pending_chain_controls + .push_back(global_backfill_barrier(backfill, certified)); + } + if !self.pending_chain_controls.is_empty() { + break; } + continue; } - } - self.log_source_ids - .retain(|filter, _| live.contains(filter)); - let live_ids: HashSet = self.log_source_ids.values().copied().collect(); - self.last_seen_log_blocks - .retain(|id, _| live_ids.contains(id)); - } - fn drain_next_scoped_batch(&mut self) -> Option> { - if self.pending_records.is_empty() { - return None; - } + let through = fetch_provider_block_ref::(&self.provider, to_block).await?; + let request_filters = + merged_lazy_backfill_filters(&filters, backfill.start_block(), through.number); + let retained = backfill.retained_anchor().copied().into_iter().collect(); + let SubscriberOwnerCatchup { + mut logs, + certified, + } = fetch_owner_catchup::<&P, N>( + &self.provider, + request_filters, + retained, + through, + SubscriberOwnerCatchupOptions { + target_preverified: true, + max_logs: self.config.max_pending_records, + max_log_bytes: self.config.max_backfill_log_bytes, + max_requests_in_flight: self.config.max_reconcile_requests_in_flight, + }, + ) + .await + .map_err(lazy_backfill_error)?; + logs.sort_by_key(|log| { + ( + log.block_number.unwrap_or_default(), + log.transaction_index.unwrap_or_default(), + log.log_index.unwrap_or_default(), + ) + }); + logs.dedup(); + self.ensure_pending_record_capacity(logs.len(), "lazy subscriber backfill records")?; - let len = self.config.max_batch_size.min(self.pending_records.len()); - let records = self.pending_records.drain(..len).collect(); - Some(SubscriberInputBatch { records }) - } + // Fetch succeeded: consume the entry, deliver, and advance the + // complete filter group through one globally ordered window. + self.pending_backfills.pop_front(); + if let Some(epoch) = epoch.as_ref() { + self.enqueue_backfilled_logs(logs, None, Some(epoch), Some(backfill)); + } else if let Some(owner) = owner.as_ref() { + self.enqueue_compat_owner_backfilled_logs(logs, owner, backfill); + } else { + self.enqueue_backfilled_logs(logs, None, None, Some(backfill)); + self.pending_chain_controls + .push_back(global_backfill_barrier(backfill, certified)); + } + for filter in &filters { + let source_id = self.log_source_id(filter); + let anchor = self + .last_seen_log_blocks + .entry(source_id) + .or_insert(certified.number); + *anchor = (*anchor).max(certified.number); + } - fn reset_delivery_state(&mut self) { - self.pending_records.clear(); - self.pending_reconcile_owner_records.clear(); - self.last_seen_log_blocks.clear(); - self.recent_input_refs.clear(); - self.recent_input_ref_set.clear(); - self.recent_owner_input_refs.clear(); - self.recent_owner_input_ref_sets.clear(); - self.pending_backfills.clear(); - self.log_source_ids.clear(); - self.next_log_source_id = 0; - self.sources_dirty = true; + if !self.pending_records.is_empty() || !self.pending_chain_controls.is_empty() { + break; + } + } + Ok(()) } - fn bump_stream_revision(&mut self) { - self.stream_revision = self.stream_revision.saturating_add(1); + fn stream_sources(&mut self) -> Result, SubscriberError> { + match resolve_subscriber_transport(self.mode)? { + SubscriberTransport::PubSub => Ok(self.pubsub_stream_sources()), + SubscriberTransport::Polling => Ok(self.polling_stream_sources()), + } } -} -impl InterestOwnerSubscriber for AlloySubscriber -where - P: Provider + Send + Sync, - N: Network + 'static, - N::HeaderResponse: Send + 'static, -{ - fn add_interest_owner( - &mut self, - owner: HandlerId, - interests: &[ReactiveInterest], - ) -> Result<(), SubscriberError> { - AlloySubscriber::add_interest_owner(self, owner, interests) - } + fn pubsub_stream_sources(&mut self) -> Vec { + let mut sources = Vec::new(); + let inherited_anchor = self.last_seen_log_blocks.values().copied().min(); - fn add_interest_owner_with_backfill( - &mut self, - owner: HandlerId, - interests: &[ReactiveInterest], - backfill: SubscriberBackfill, - ) -> Result<(), SubscriberError> { - AlloySubscriber::add_interest_owner_with_backfill(self, owner, interests, backfill) - } + for filter in self.log_stream_filters() { + let id = self.log_source_id(&filter); + if let Some(anchor) = inherited_anchor { + self.last_seen_log_blocks.entry(id).or_insert(anchor); + } + sources.push(SubscriberStreamSource::PubSubLog { id, filter }); + } - fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option>> { - AlloySubscriber::remove_interest_owner(self, owner) - } + if needs_pending_hash_stream(&self.interests) { + sources.push(SubscriberStreamSource::PubSubPendingHashes); + } - fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest]> { - AlloySubscriber::owner_interests(self, owner) - } -} + if needs_header_block_stream(&self.interests) { + sources.push(SubscriberStreamSource::PubSubBlockHeaders); + } -enum AlloySubscriberState { - Uninitialized, - Active(SubscriberStreams), - Empty, -} + if self.config.preconfirmations != PreconfirmationMode::Disabled { + match self.chain_id.and_then(flashblocks_adapter) { + Some(FlashblocksAdapter::BaseNative) => { + sources.push(SubscriberStreamSource::BaseFlashblocks); + for filter in self.log_stream_filters() { + let id = self.log_source_id(&filter); + sources.push(SubscriberStreamSource::BasePendingLog { id, filter }); + } + } + Some(FlashblocksAdapter::OpPending) => { + sources.push(SubscriberStreamSource::OpPendingFlashblocks); + } + None => {} + } + } -struct SubscriberStreams { - entries: Vec>, - next_index: usize, -} + sources + } -struct SubscriberStreamEntry { - source: SubscriberStreamSource, - stream: BoxStream<'static, SubscriberEvent>, -} + fn polling_stream_sources(&self) -> Vec { + let mut sources = Vec::new(); -impl SubscriberStreams { - fn new() -> Self { - Self { - entries: Vec::new(), - next_index: 0, + for filter in self.log_stream_filters() { + sources.push(SubscriberStreamSource::PollingLog { filter }); } - } - fn is_empty(&self) -> bool { - self.entries.is_empty() - } + if needs_pending_hash_stream(&self.interests) { + sources.push(SubscriberStreamSource::PollingPendingHashes); + } - fn push( - &mut self, - source: SubscriberStreamSource, - stream: BoxStream<'static, SubscriberEvent>, - ) { - self.entries.push(SubscriberStreamEntry { source, stream }); - } + if self.config.preconfirmations != PreconfirmationMode::Disabled + && self.chain_id.and_then(flashblocks_adapter) == Some(FlashblocksAdapter::OpPending) + { + sources.push(SubscriberStreamSource::OpPendingFlashblocks); + } - #[cfg(all(test, feature = "reactive-ws"))] - fn len(&self) -> usize { - self.entries.len() + sources } - fn contains_source(&self, source: &SubscriberStreamSource) -> bool { - self.entries - .iter() - .any(|entry| entry.source.same_key(source)) - } + fn log_source_id(&mut self, filter: &Filter) -> usize { + if let Some(id) = self.log_source_ids.get(filter) { + return *id; + } - fn retain_sources(&mut self, sources: &[SubscriberStreamSource]) { - self.entries - .retain(|entry| sources.iter().any(|source| entry.source.same_key(source))); - self.normalize_next_index(); + let id = self.next_log_source_id; + self.next_log_source_id = self.next_log_source_id.saturating_add(1); + self.log_source_ids.insert(filter.clone(), id); + id } - fn normalize_next_index(&mut self) { - if self.entries.is_empty() { - self.next_index = 0; - } else if self.next_index >= self.entries.len() { - self.next_index %= self.entries.len(); + async fn connect_source_stream( + &mut self, + source: SubscriberStreamSource, + ) -> Result>, SubscriberError> { + match source { + SubscriberStreamSource::PubSubLog { id, filter } => { + self.connect_pubsub_log_stream(id, filter).await + } + SubscriberStreamSource::BasePendingLog { id, filter } => { + self.connect_base_pending_log_stream(id, filter).await + } + SubscriberStreamSource::BaseFlashblocks => self.connect_base_flashblock_stream().await, + SubscriberStreamSource::OpPendingFlashblocks => { + self.connect_op_flashblock_tick_stream() + } + SubscriberStreamSource::PubSubPendingHashes => { + self.connect_pubsub_pending_hash_stream().await + } + SubscriberStreamSource::PubSubBlockHeaders => { + self.connect_pubsub_block_header_stream().await + } + SubscriberStreamSource::PollingLog { filter } => { + self.connect_polling_log_stream(filter).await + } + SubscriberStreamSource::PollingPendingHashes => { + self.connect_polling_pending_hash_stream().await + } } } - async fn next(&mut self) -> Option> { - poll_fn(|cx| { - self.normalize_next_index(); - if self.entries.is_empty() { - return std::task::Poll::Ready(None); - } - - let mut index = self.next_index; - let mut checked = 0usize; - while checked < self.entries.len() { - if index >= self.entries.len() { - index = 0; - } - match self.entries[index].stream.as_mut().poll_next(cx) { - std::task::Poll::Ready(Some(event)) => { - if matches!(event, SubscriberEvent::StreamTerminated(_)) { - self.entries.remove(index); - self.next_index = if self.entries.is_empty() { - 0 - } else { - index % self.entries.len() - }; - } else { - self.next_index = (index + 1) % self.entries.len(); - } - return std::task::Poll::Ready(Some(event)); - } - std::task::Poll::Ready(None) => { - self.entries.remove(index); - if self.entries.is_empty() { - self.next_index = 0; - return std::task::Poll::Ready(None); - } - } - std::task::Poll::Pending => { - checked += 1; - index += 1; - } - } - } + async fn connect_pubsub_log_stream( + &mut self, + id: usize, + filter: Filter, + ) -> Result>, SubscriberError> { + #[cfg(feature = "reactive-ws")] + { + let source = SubscriberStreamSource::PubSubLog { + id, + filter: filter.clone(), + }; + let stream = self + .provider + .subscribe_logs(&filter) + .channel_size(self.config.max_batch_size.max(1)) + .await + .map_err(provider_error)? + .into_stream() + .map(move |log| SubscriberEvent::Log { source_id: id, log }); + Ok(stream_with_termination(stream, source)) + } - if self.entries.is_empty() { - std::task::Poll::Ready(None) - } else { - self.next_index = index % self.entries.len(); - std::task::Poll::Pending - } - }) - .await + #[cfg(not(feature = "reactive-ws"))] + { + let _ = (id, filter); + Err(SubscriberError::Unsupported( + "AlloySubscriber pubsub mode requires the reactive-ws feature", + )) + } } -} -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[allow(dead_code)] -enum SubscriberTransport { - PubSub, - Polling, -} + async fn connect_base_pending_log_stream( + &mut self, + id: usize, + filter: Filter, + ) -> Result>, SubscriberError> { + #[cfg(feature = "reactive-ws")] + { + let source = SubscriberStreamSource::BasePendingLog { + id, + filter: filter.clone(), + }; + let params = base_pending_log_filter(&filter)?; + let stream = self + .provider + .subscribe::<_, Log>(("pendingLogs", params)) + .channel_size(self.config.max_batch_size.max(1)) + .await + .map_err(provider_error)? + .into_stream() + .map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log }); + Ok(stream_with_termination(stream, source)) + } -#[derive(Clone, Debug)] -enum SubscriberStreamSource { - PubSubLog { id: usize, filter: Filter }, - PubSubPendingHashes, - PubSubBlockHeaders, - PollingLog { filter: Filter }, - PollingPendingHashes, -} + #[cfg(not(feature = "reactive-ws"))] + { + let _ = (id, filter); + Err(SubscriberError::Unsupported( + "Base Flashblocks require the reactive-ws feature", + )) + } + } -impl SubscriberStreamSource { - fn label(&self) -> &'static str { - match self { - Self::PubSubLog { .. } => "pubsub log", - Self::PubSubPendingHashes => "pubsub pending transaction hash", - Self::PubSubBlockHeaders => "pubsub block header", - Self::PollingLog { .. } => "polling log", - Self::PollingPendingHashes => "polling pending transaction hash", + async fn connect_base_flashblock_stream( + &mut self, + ) -> Result>, SubscriberError> { + #[cfg(feature = "reactive-ws")] + { + let stream = self + .provider + .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",)) + .channel_size(self.config.max_batch_size.max(1)) + .await + .map_err(provider_error)? + .into_stream() + .map(SubscriberEvent::BaseFlashblock); + Ok(stream_with_termination( + stream, + SubscriberStreamSource::BaseFlashblocks, + )) + } + + #[cfg(not(feature = "reactive-ws"))] + { + Err(SubscriberError::Unsupported( + "Base Flashblocks require the reactive-ws feature", + )) } } - fn is_pubsub(&self) -> bool { - matches!( - self, - Self::PubSubLog { .. } | Self::PubSubPendingHashes | Self::PubSubBlockHeaders - ) + fn connect_op_flashblock_tick_stream( + &self, + ) -> Result>, SubscriberError> { + let interval = tokio::time::interval(self.config.flashblock_poll_interval); + let stream = stream::unfold(interval, |mut interval| async move { + interval.tick().await; + Some((SubscriberEvent::OpFlashblockTick, interval)) + }); + Ok(stream_with_termination( + stream, + SubscriberStreamSource::OpPendingFlashblocks, + )) } - fn same_key(&self, other: &Self) -> bool { - match (self, other) { - (Self::PubSubLog { filter: left, .. }, Self::PubSubLog { filter: right, .. }) - | (Self::PollingLog { filter: left }, Self::PollingLog { filter: right }) => { - left == right - } - (Self::PubSubPendingHashes, Self::PubSubPendingHashes) - | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders) - | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true, - _ => false, + async fn connect_pubsub_pending_hash_stream( + &mut self, + ) -> Result>, SubscriberError> { + #[cfg(feature = "reactive-ws")] + { + let stream = self + .provider + .subscribe_pending_transactions() + .channel_size(self.config.max_batch_size.max(1)) + .await + .map_err(provider_error)? + .into_stream() + .map(SubscriberEvent::PendingHash); + Ok(stream_with_termination( + stream, + SubscriberStreamSource::PubSubPendingHashes, + )) } - } -} -#[allow(dead_code)] -enum SubscriberEvent { - Log { source_id: usize, log: Log }, - BackfilledLogs { source_id: usize, logs: Vec }, - Logs(Vec), - BlockHeader(N::HeaderResponse), - PendingHash(B256), - PendingHashes(Vec), - StreamTerminated(SubscriberStreamSource), -} + #[cfg(not(feature = "reactive-ws"))] + { + Err(SubscriberError::Unsupported( + "AlloySubscriber pubsub mode requires the reactive-ws feature", + )) + } + } -impl EventSubscriber for AlloySubscriber -where - P: Provider + Send + Sync, - N: Network + 'static, - N::HeaderResponse: Send + 'static, -{ - fn register_interests( + async fn connect_pubsub_block_header_stream( &mut self, - interests: &[ReactiveInterest], - ) -> Result<(), SubscriberError> { - validate_subscriber_config(&self.config)?; - validate_supported_interests(self.mode, &self.config, interests)?; + ) -> Result>, SubscriberError> { + #[cfg(feature = "reactive-ws")] + { + let stream = self + .provider + .subscribe_blocks() + .channel_size(self.config.max_batch_size.max(1)) + .await + .map_err(provider_error)? + .into_stream() + .map(SubscriberEvent::BlockHeader); + Ok(stream_with_termination( + stream, + SubscriberStreamSource::PubSubBlockHeaders, + )) + } - self.base_interests = interests.to_vec(); - self.owned_interests.clear(); - self.rebuild_registered_interests(); - self.reset_delivery_state(); - self.state = AlloySubscriberState::Uninitialized; - Ok(()) + #[cfg(not(feature = "reactive-ws"))] + { + Err(SubscriberError::Unsupported( + "AlloySubscriber pubsub mode requires the reactive-ws feature", + )) + } } - fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> { - Box::pin(async { - Ok(self - .next_scoped_batch() - .await? - .map(SubscriberInputBatch::into_reactive_batch)) - }) + async fn connect_polling_log_stream( + &mut self, + filter: Filter, + ) -> Result>, SubscriberError> { + #[cfg(feature = "reactive-polling")] + { + let source = SubscriberStreamSource::PollingLog { + filter: filter.clone(), + }; + let stream = self + .provider + .watch_logs(&filter) + .await + .map_err(provider_error)? + .with_channel_size(self.config.max_batch_size.max(1)) + .into_stream() + .map(SubscriberEvent::Logs); + Ok(stream_with_termination(stream, source)) + } + + #[cfg(not(feature = "reactive-polling"))] + { + let _ = filter; + Err(SubscriberError::Unsupported( + "AlloySubscriber polling mode requires the reactive-polling feature", + )) + } } -} -impl AlloySubscriber -where - P: Provider + Send + Sync, - N: Network + 'static, - N::HeaderResponse: Send + 'static, -{ - /// Subscribe first, then catch an exact staged owner up through a verified - /// canonical block. - /// - /// This compatibility wrapper delegates to - /// [`reconcile_interest_owners`](Self::reconcile_interest_owners), so a - /// driver adopting several owners should call the bulk API once rather than - /// invoking this method in a loop. - pub async fn reconcile_interest_owner( + async fn connect_polling_pending_hash_stream( &mut self, - epoch: &SubscriberOwnerEpoch, - through: BlockRef, - ) -> Result - where - P: Clone, - { - self.reconcile_interest_owners(std::slice::from_ref(epoch), through) - .await? - .pop() - .ok_or(SubscriberOwnerError::NotStaged) + ) -> Result>, SubscriberError> { + #[cfg(feature = "reactive-polling")] + { + let stream = self + .provider + .watch_pending_transactions() + .await + .map_err(provider_error)? + .with_channel_size(self.config.max_batch_size.max(1)) + .into_stream() + .map(SubscriberEvent::PendingHashes); + Ok(stream_with_termination( + stream, + SubscriberStreamSource::PollingPendingHashes, + )) + } + + #[cfg(not(feature = "reactive-polling"))] + { + Err(SubscriberError::Unsupported( + "AlloySubscriber polling mode requires the reactive-polling feature", + )) + } + } + + async fn next_event(&mut self) -> Result>, SubscriberError> { + loop { + let event = match &mut self.state { + AlloySubscriberState::Active(streams) => streams.next().await, + AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => { + return Ok(None); + } + }; + + let Some(event) = event else { + return Err(SubscriberError::Provider( + "Alloy subscriber streams terminated before the subscriber was stopped" + .to_owned(), + )); + }; + + match event { + SubscriberEvent::StreamTerminated(source) => { + // Persist the missing-source intent before the first await. + // If a control command cancels this poll during reconnect, + // the next poll will reconcile the desired/live diff. + self.sources_dirty = true; + self.bump_stream_revision(); + if source.is_flashblocks() { + self.reset_flashblock_tracking(); + } + if let Some(backfill_event) = self.reconnect_source_stream(source).await? { + self.sources_dirty = false; + if let Some(backfill_event) = + self.normalize_flashblock_event(backfill_event).await? + { + self.verify_event_log_blocks(&backfill_event).await?; + return Ok(Some(backfill_event)); + } + } + self.sources_dirty = false; + } + event => { + let Some(event) = self.normalize_flashblock_event(event).await? else { + continue; + }; + self.verify_event_log_blocks(&event).await?; + return Ok(Some(event)); + } + } + } } - /// Subscribe first, then atomically catch staged owners up through one - /// verified canonical block. - /// - /// All epochs are preflighted before provider I/O. Live streams are - /// reconciled once, compatible provider filters are merged into bounded - /// chunks, and every historical request shares one double target-header - /// certification. Provider-filter supersets are routed back through each - /// owner's exact interests, retaining owner-scoped delivery provenance. - /// Duplicate epoch tokens in `epochs` are coalesced in first-seen order. - /// - /// Live events are continuously drained while an independent provider - /// clone performs catch-up. Fetched owner records and progress become - /// visible only after every request and the final certification succeed. A - /// failure leaves every target staged with its prior progress unchanged; - /// live canonical delivery consumed during the attempt is preserved while - /// excluding the failed target epochs from its staged-owner audience. - pub async fn reconcile_interest_owners( + async fn normalize_flashblock_event( &mut self, - epochs: &[SubscriberOwnerEpoch], - through: BlockRef, - ) -> Result, SubscriberOwnerError> - where - P: Clone, - { - if epochs.is_empty() { - return Ok(Vec::new()); - } + event: SubscriberEvent, + ) -> Result>, SubscriberError> { + match event { + SubscriberEvent::BasePendingLog { source_id, log } => { + let hash = log.block_hash.ok_or_else(|| { + SubscriberError::Provider( + "Base pendingLogs item is missing its partial block hash".into(), + ) + })?; + let Some(flashblock) = self.flashblocks_by_hash.get(&hash).cloned() else { + if self.unmatched_pending_logs.len() >= self.config.max_pending_records { + return Err(SubscriberError::ResourceExhausted( + "unmatched Base pendingLogs exceeded max_pending_records".into(), + )); + } + self.unmatched_pending_logs.push_back((source_id, log)); + return Ok(None); + }; + let logs = self.filter_preconfirmed_logs(&flashblock, vec![log])?; + Ok(Some(if logs.is_empty() { + SubscriberEvent::FlashblockObserved + } else { + SubscriberEvent::PreconfirmedLogs { flashblock, logs } + })) + } + SubscriberEvent::BaseFlashblock(payload) => { + let (flashblock, recover_pending_snapshot) = + self.accept_base_flashblock(payload)?; + let mut logs = Vec::new(); + let mut retained = VecDeque::new(); + while let Some((source_id, log)) = self.unmatched_pending_logs.pop_front() { + if log.block_hash == Some(flashblock.block_hash) { + let _ = source_id; + logs.push(log); + } else { + retained.push_back((source_id, log)); + } + } + self.unmatched_pending_logs = retained; - let mut seen = HashSet::new(); - let mut plans = Vec::with_capacity(epochs.len()); - for epoch in epochs { - if !seen.insert(epoch.clone()) { - continue; + if recover_pending_snapshot + && let Some(event) = self.fetch_pending_flashblock().await? + { + return Ok(Some(event)); + } + let logs = self.filter_preconfirmed_logs(&flashblock, logs)?; + Ok(Some(if logs.is_empty() { + SubscriberEvent::FlashblockObserved + } else { + SubscriberEvent::PreconfirmedLogs { flashblock, logs } + })) } - let entry = self - .owned_interests - .iter() - .find(|entry| { - entry.epoch.as_ref() == Some(epoch) - && entry.state == SubscriberOwnerState::Staged - }) - .ok_or(SubscriberOwnerError::NotStaged)?; - let position = entry - .progress - .as_ref() - .map(|progress| &progress.through) - .or(entry.baseline.as_ref()) - .ok_or(SubscriberOwnerError::MissingBaseline)?; - let baseline = position.number; - if through.number < baseline { - return Err(SubscriberOwnerError::ProgressRegression { - current: baseline, - target: through.number, - }); + SubscriberEvent::OpFlashblockTick => self.fetch_pending_flashblock().await, + SubscriberEvent::PreconfirmedLogs { flashblock, logs } => { + let logs = self.filter_preconfirmed_logs(&flashblock, logs)?; + Ok(Some(if logs.is_empty() { + SubscriberEvent::FlashblockObserved + } else { + SubscriberEvent::PreconfirmedLogs { flashblock, logs } + })) } - let from_block = baseline - .checked_add(1) - .ok_or(SubscriberOwnerError::PostBlockOverflow(baseline))?; - if through.number == baseline && through.hash != position.hash { - return Err(SubscriberOwnerError::ProgressConflict { - number: baseline, - current_hash: position.hash, - target_hash: through.hash, + SubscriberEvent::FlashblockObserved => Ok(None), + event => Ok(Some(event)), + } + } + + fn accept_base_flashblock( + &mut self, + payload: BaseFlashblockWirePayload, + ) -> Result<(FlashblockRef, bool), SubscriberError> { + let provider = self.provider_ref.clone().ok_or({ + SubscriberError::InvalidConfig( + "Flashblocks require a stable provider ref from a pinned provider lease", + ) + })?; + + let (flashblock, recover_pending_snapshot) = match payload { + BaseFlashblockWirePayload::Indexed(payload) => { + if payload.index == 0 { + let base = payload.base.clone().ok_or_else(|| { + SubscriberError::Provider( + "Base newFlashblocks index zero omitted its base header".into(), + ) + })?; + self.base_flashblock_header = Some((payload.payload_id, base)); + } + + let base = self + .base_flashblock_header + .as_ref() + .filter(|(payload_id, _)| *payload_id == payload.payload_id) + .map(|(_, base)| base); + let block_number = base.map(|base| base.block_number).or_else(|| { + payload + .metadata + .as_ref() + .map(|metadata| metadata.block_number) }); + let block_number = block_number.ok_or_else(|| { + SubscriberError::Provider( + "Base newFlashblocks payload omitted both base and metadata block number" + .into(), + ) + })?; + let flashblock = FlashblockRef { + provider, + payload_id: Some(payload.payload_id), + index: Some(payload.index), + block_number, + block_hash: payload.diff.block_hash, + parent_hash: base.map(|base| base.parent_hash), + state_root: Some(payload.diff.state_root), + timestamp: base.map(|base| base.timestamp), + }; + let recover = match self.latest_preconfirmation.as_ref() { + Some(previous) if previous.same_payload(&flashblock) => { + if let (Some(previous), Some(current)) = (previous.index, flashblock.index) + { + if current < previous { + return Ok((flashblock, false)); + } + current > previous.saturating_add(1) + } else { + false + } + } + Some(_) => payload.index != 0, + None => payload.index != 0, + }; + (flashblock, recover) } - if through.number == from_block - && through - .parent_hash - .is_some_and(|parent| parent != position.hash) - { - return Err(SubscriberOwnerError::ProgressConflict { - number: baseline, - current_hash: position.hash, - target_hash: through.parent_hash.expect("checked as present above"), - }); + BaseFlashblockWirePayload::Block(payload) => { + let index = self + .latest_preconfirmation + .as_ref() + .filter(|previous| { + previous.block_number == payload.number + && previous.parent_hash == Some(payload.parent_hash) + }) + .and_then(|previous| previous.index) + .map_or(0, |index| index.saturating_add(1)); + ( + FlashblockRef { + provider, + payload_id: None, + index: Some(index), + block_number: payload.number, + block_hash: payload.hash, + parent_hash: Some(payload.parent_hash), + state_root: Some(payload.state_root), + timestamp: Some(payload.timestamp), + }, + false, + ) } - if entry - .interests - .iter() - .any(|interest| !matches!(interest, ReactiveInterest::Logs(_))) - { - return Err(SubscriberOwnerError::UnsupportedPostBlockInterest); + }; + + self.flashblocks_by_hash + .insert(flashblock.block_hash, flashblock.clone()); + self.flashblock_hash_order.push_back(flashblock.block_hash); + while self.flashblock_hash_order.len() > 64 { + if let Some(hash) = self.flashblock_hash_order.pop_front() { + self.flashblocks_by_hash.remove(&hash); } - plans.push(SubscriberOwnerReconcilePlan { - epoch: epoch.clone(), - interests: entry.interests.clone(), - retained: position.clone(), - from_block, - }); } + Ok((flashblock, recover_pending_snapshot)) + } - // The ordering is intentional and part of the public continuity - // contract: connect first, then fetch the bounded historical window. - self.ensure_streams().await?; - let provider = self.provider.clone(); - let filters = merged_owner_reconcile_filters(&plans, through.number); - let retained = plans.iter().map(|plan| plan.retained.clone()).collect(); - let target_epochs: HashSet<_> = plans.iter().map(|plan| plan.epoch.clone()).collect(); - let fetch = fetch_owner_catchup::(provider, filters, retained, through); - let SubscriberOwnerCatchup { logs, certified } = - self.drive_reconcile_fetch(fetch, &target_epochs).await?; + async fn fetch_pending_flashblock( + &mut self, + ) -> Result>, SubscriberError> { + let latest = self + .provider + .get_block_number() + .await + .map_err(provider_error)?; + let Some(block) = self + .provider + .get_block_by_number(BlockNumberOrTag::Pending) + .await + .map_err(provider_error)? + else { + if self.config.preconfirmations == PreconfirmationMode::Required { + return Err(SubscriberError::Provider( + "Flashblocks provider returned no pending block".into(), + )); + } + return Ok(None); + }; + let header = block.header(); + if header.number() <= latest { + if self.config.preconfirmations == PreconfirmationMode::Required { + return Err(SubscriberError::Provider( + "Flashblocks provider pending state did not advance beyond the canonical head" + .into(), + )); + } + return Ok(None); + } - self.pending_backfills.retain(|queued| { - queued - .epoch - .as_ref() - .is_none_or(|epoch| !target_epochs.contains(epoch)) + let provider = self.provider_ref.clone().ok_or({ + SubscriberError::InvalidConfig( + "Flashblocks require a stable provider ref from a pinned provider lease", + ) + })?; + let parent_hash = Some(header.parent_hash()); + let index = self.latest_preconfirmation.as_ref().and_then(|previous| { + (previous.block_number == header.number() && previous.parent_hash == parent_hash) + .then(|| previous.index.unwrap_or(0).saturating_add(1)) }); - let records = logs - .into_iter() - .map(|log| log_input_record(log, InputSource::Backfill)) - .collect(); - for record in dedupe_records(sort_records(records)) { - let block_number = match &record.input { - ReactiveInput::Log(log) => log - .block_number - .expect("bulk catch-up logs were validated before commit"), - _ => unreachable!("bulk owner catch-up contains log records only"), - }; - let owners = plans - .iter() - .filter(|plan| block_number >= plan.from_block) - .filter(|plan| { - plan.interests - .iter() - .any(|interest| interest_matches(interest, &record.input)) - }) - .map(|plan| plan.epoch.clone()) - .collect(); - self.enqueue_owner_record_for_owners_unmerged(record, owners); + let flashblock = FlashblockRef { + provider, + payload_id: None, + index: Some(index.unwrap_or(0)), + block_number: header.number(), + block_hash: header.hash(), + parent_hash, + state_root: Some(header.state_root()), + timestamp: Some(header.timestamp()), + }; + if self + .latest_preconfirmation + .as_ref() + .is_some_and(|previous| { + previous.block_hash == flashblock.block_hash + && previous.block_number == flashblock.block_number + }) + { + return Ok(None); } - self.promote_reconcile_owner_records(&target_epochs); - self.seed_reconciled_filter_anchors(&plans, certified.number); - let stream_revision = self.stream_revision; - let mut progress = Vec::with_capacity(plans.len()); - for plan in plans { - let item = SubscriberOwnerProgress { - owner: plan.epoch.clone(), - through: certified.clone(), - }; - let entry = self - .owned_interests - .iter_mut() - .find(|entry| entry.epoch.as_ref() == Some(&plan.epoch)) - .expect("bulk reconcile holds exclusive access after epoch preflight"); - entry.progress = Some(item.clone()); - entry.progress_stream_revision = Some(stream_revision); - progress.push(item); + let logs = self.fetch_pending_logs().await?; + let logs = self.filter_preconfirmed_logs(&flashblock, logs)?; + Ok(Some(if logs.is_empty() { + SubscriberEvent::FlashblockObserved + } else { + SubscriberEvent::PreconfirmedLogs { flashblock, logs } + })) + } + + async fn fetch_pending_logs(&mut self) -> Result, SubscriberError> { + let mut logs = Vec::new(); + for filter in self.log_stream_filters() { + let filter = filter + .from_block(BlockNumberOrTag::Pending) + .to_block(BlockNumberOrTag::Pending); + logs.extend( + self.provider + .get_logs(&filter) + .await + .map_err(provider_error)?, + ); } - Ok(progress) + Ok(logs) } - async fn drive_reconcile_fetch( + fn filter_preconfirmed_logs( &mut self, - fetch: F, - target_epochs: &HashSet, - ) -> Result - where - F: Future>, - { - if !matches!(&self.state, AlloySubscriberState::Active(_)) { - return fetch.await; + flashblock: &FlashblockRef, + mut logs: Vec, + ) -> Result, SubscriberError> { + if self + .latest_preconfirmation + .as_ref() + .is_none_or(|previous| !previous.same_payload(flashblock)) + { + self.preconfirmed_seen_logs.clear(); } - let mut fetch = Box::pin(fetch); - loop { - let event = { - let live = Box::pin(self.next_event()); - match select(fetch, live).await { - Either::Left((result, pending_live)) => { - drop(pending_live); - return result; - } - Either::Right((event, pending_fetch)) => { - fetch = pending_fetch; - event - } - } - }; - let event = event?.ok_or_else(|| { + if let Some(previous) = self.latest_preconfirmation.as_ref() + && previous.same_payload(flashblock) + && let (Some(previous_index), Some(current_index)) = (previous.index, flashblock.index) + && current_index < previous_index + { + return Ok(Vec::new()); + } + self.latest_preconfirmation = Some(flashblock.clone()); + + logs.sort_by_key(|log| (log.transaction_index.unwrap_or(u64::MAX), log.log_index)); + let mut filtered = Vec::new(); + for log in logs { + if log.removed + || log.block_number != Some(flashblock.block_number) + || log.block_hash != Some(flashblock.block_hash) + { + return Err(SubscriberError::Provider( + "pre-confirmed log disagrees with its Flashblock snapshot".into(), + )); + } + let transaction_hash = log.transaction_hash.ok_or_else(|| { SubscriberError::Provider( - "Alloy subscriber streams ended during owner reconcile".to_owned(), + "pre-confirmed log is missing its transaction hash".into(), ) })?; - self.buffer_reconcile_event_for_owners(&event, target_epochs); - self.enqueue_event_excluding_owners(event, target_epochs); + let log_index = log.log_index.ok_or_else(|| { + SubscriberError::Provider("pre-confirmed log is missing its log index".into()) + })?; + if self + .preconfirmed_seen_logs + .insert((transaction_hash, log_index)) + && log_matches_any_interest(&log, &self.interests) + { + filtered.push(log); + } } + Ok(filtered) } - /// Poll one driver control future with priority over the next scoped batch. - /// - /// This is the supported control-interleaving primitive for a subscriber - /// driver. `control` is borrowed rather than consumed, so a batch win leaves - /// the caller's pending control future alive. When control wins, the - /// in-progress subscriber poll is cancelled at a documented safe boundary: - /// queued records are removed only when a complete batch is returned, - /// successful backfill steps are committed before the next await, provider - /// streams created but not installed are dropped, and installed streams - /// remain owned by the subscriber for the next call. - /// - /// The control future is polled first. Therefore a ready shutdown/removal - /// command cannot starve behind a continuously ready subscriber queue. - pub async fn next_scoped_batch_or( + async fn verify_event_log_blocks( &mut self, - control: Pin<&mut F>, - ) -> Result, SubscriberError> - where - C: Send, - F: Future + Send, - { - let batch = self.next_scoped_batch(); - match select(control, batch).await { - Either::Left((control, pending_batch)) => { - drop(pending_batch); - Ok(SubscriberDriverPoll::Control(control)) + event: &SubscriberEvent, + ) -> Result<(), SubscriberError> { + if !self.config.verify_log_block_context { + return Ok(()); + } + match event { + SubscriberEvent::Log { log, .. } => self.verify_log_block_context(log).await, + SubscriberEvent::BackfilledLogs { logs, .. } | SubscriberEvent::Logs(logs) => { + for log in logs { + self.verify_log_block_context(log).await?; + } + Ok(()) } - Either::Right((batch, _pending_control)) => batch.map(SubscriberDriverPoll::Batch), + SubscriberEvent::BlockHeader(_) + | SubscriberEvent::PendingHash(_) + | SubscriberEvent::PendingHashes(_) + | SubscriberEvent::BasePendingLog { .. } + | SubscriberEvent::BaseFlashblock(_) + | SubscriberEvent::OpFlashblockTick + | SubscriberEvent::PreconfirmedLogs { .. } + | SubscriberEvent::FlashblockObserved + | SubscriberEvent::StreamTerminated(_) => Ok(()), } } - /// Return the next subscriber batch while retaining staged-owner delivery - /// provenance captured at enqueue time. - /// - /// Transaction-aware drivers must use this method. The compatibility - /// [`EventSubscriber::next_batch`] method flattens the same queue and keeps - /// its historical behavior for existing callers. - /// - /// For command interleaving, prefer - /// [`next_scoped_batch_or`](Self::next_scoped_batch_or), which preserves the - /// cancellation-safety invariants of this poll and prioritizes ready control. - pub fn next_scoped_batch(&mut self) -> SubscriberNextScopedBatch<'_, N> { - Box::pin(async { - if let Some(batch) = self.drain_next_scoped_batch() { - return Ok(Some(batch)); + async fn verify_log_block_context(&mut self, log: &Log) -> Result<(), SubscriberError> { + if log.removed { + return Ok(()); + } + let number = log.block_number.ok_or_else(|| { + SubscriberError::Provider( + "canonical log is missing its block number during context verification".into(), + ) + })?; + let hash = log.block_hash.ok_or_else(|| { + SubscriberError::Provider( + "canonical log is missing its block hash during context verification".into(), + ) + })?; + let key = (number, hash); + if self.verified_log_blocks.contains_key(&key) { + return Ok(()); + } + let provider = self + .log_verification_provider + .as_ref() + .unwrap_or(&self.provider); + let block = provider + .get_block_by_number(BlockNumberOrTag::Number(number)) + .await + .map_err(provider_error)? + .ok_or_else(|| { + SubscriberError::Provider(format!( + "canonical log block {number} is unavailable during context verification" + )) + })?; + let header = block.header(); + let verified = BlockRef { + number: header.number(), + hash: header.hash(), + parent_hash: Some(header.parent_hash()), + timestamp: Some(header.timestamp()), + }; + if verified.number != number + || verified.hash != hash + || log + .block_timestamp + .is_some_and(|timestamp| verified.timestamp != Some(timestamp)) + { + return Err(SubscriberError::Provider(format!( + "canonical log block {number}:{hash:?} disagrees with the provider's current canonical identity" + ))); + } + self.verified_log_blocks.insert(key, verified); + self.verified_log_block_order.push_back(key); + let capacity = self.config.reconnect.dedupe_window.max(1); + while self.verified_log_block_order.len() > capacity { + if let Some(evicted) = self.verified_log_block_order.pop_front() { + self.verified_log_blocks.remove(&evicted); } + } + Ok(()) + } - self.drain_pending_backfills().await?; - if let Some(batch) = self.drain_next_scoped_batch() { - return Ok(Some(batch)); - } + fn enqueue_event(&mut self, event: SubscriberEvent) { + self.enqueue_event_with_excluded_owners(event, None); + } - self.ensure_streams().await?; - if let Some(batch) = self.drain_next_scoped_batch() { - return Ok(Some(batch)); + fn buffer_reconcile_event_for_owners( + &mut self, + event: &SubscriberEvent, + target_epochs: &HashSet, + ) { + match event { + SubscriberEvent::Log { log, .. } => { + self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs) } - - if self.interests.is_empty() { - return Ok(None); + SubscriberEvent::BackfilledLogs { logs, .. } => { + for log in logs { + self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs); + } + } + SubscriberEvent::Logs(logs) => { + for log in logs { + self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs); + } } + SubscriberEvent::BlockHeader(_) + | SubscriberEvent::PendingHash(_) + | SubscriberEvent::PendingHashes(_) + | SubscriberEvent::BasePendingLog { .. } + | SubscriberEvent::BaseFlashblock(_) + | SubscriberEvent::OpFlashblockTick + | SubscriberEvent::PreconfirmedLogs { .. } + | SubscriberEvent::FlashblockObserved + | SubscriberEvent::StreamTerminated(_) => {} + } + } - loop { - let Some(event) = self.next_event().await? else { - return Ok(None); - }; + fn buffer_reconcile_log_for_owners( + &mut self, + log: &Log, + source: InputSource, + target_epochs: &HashSet, + ) { + let record = self.with_chain_id(log_input_record(log.clone(), source)); + let owners = self + .staged_owners_for_record(&record) + .into_iter() + .filter(|owner| target_epochs.contains(owner)) + .collect::>(); + if !owners.is_empty() { + self.push_pending_reconcile_record(BufferedSubscriberOwnerRecord { record, owners }); + } + } - self.enqueue_event(event); - if let Some(batch) = self.drain_next_scoped_batch() { - return Ok(Some(batch)); + fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet) { + let mut retained = VecDeque::new(); + while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() { + let mut promoted = Vec::new(); + buffered.owners.retain(|owner| { + if target_epochs.contains(owner) { + promoted.push(owner.clone()); + false + } else { + true } + }); + if promoted.is_empty() { + retained.push_back(buffered); + continue; } - }) + let promoted_record = if buffered.owners.is_empty() { + buffered.record + } else { + let record = buffered.record.clone(); + retained.push_back(buffered); + record + }; + self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted); + } + self.pending_reconcile_owner_records = retained; } - /// Bring live streams in line with the current interest set. - /// - /// Runs incrementally: the desired-vs-live diff only happens when interest - /// bookkeeping changed since the last successful pass (`sources_dirty`), so - /// steady-state polling costs nothing here. Missing sources are connected, - /// sources for retired filters are dropped (dropping an Alloy subscription - /// unsubscribes provider-side), and unrelated live streams — with their - /// delivery and anchor state — are left untouched. - /// - /// A newly connected log source whose filter already has a delivery anchor - /// is caught up from that anchor immediately after subscribing (the same - /// subscribe-then-backfill order the reconnect path uses). Together with - /// anchor seeding in [`Self::drain_pending_backfills`], that closes the - /// window between an adoption backfill and live stream start. - async fn ensure_streams(&mut self) -> Result<(), SubscriberError> { - if !self.sources_dirty { - return Ok(()); - } - // An interest-less subscriber never touches the provider - // ([`EventSubscriber::next_batch`] returns `Ok(None)`). Still certify - // the empty desired topology as clean so a deliberately empty staged - // epoch can reconcile and activate instead of remaining dirty forever. - if matches!(self.state, AlloySubscriberState::Uninitialized) && self.interests.is_empty() { - self.bump_stream_revision(); - self.sources_dirty = false; - return Ok(()); + fn seed_reconciled_filter_anchors( + &mut self, + plans: &[SubscriberOwnerReconcilePlan], + through: u64, + ) { + for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) { + let Some(source_id) = self.log_source_ids.get(&filter).copied() else { + continue; + }; + let anchor = self + .last_seen_log_blocks + .entry(source_id) + .or_insert(through); + *anchor = (*anchor).max(through); } + } - let desired = self.stream_sources()?; - let missing: Vec = match &self.state { - AlloySubscriberState::Active(streams) => desired - .iter() - .filter(|source| !streams.contains_source(source)) - .cloned() - .collect(), - AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => desired.clone(), - }; + fn enqueue_event_excluding_owners( + &mut self, + event: SubscriberEvent, + excluded: &HashSet, + ) { + self.enqueue_event_with_excluded_owners(event, Some(excluded)); + } - let mut connected = Vec::new(); - for source in missing { - let stream = self.connect_source_stream(source.clone()).await?; - // Anchored catch-up for a source with a known delivery watermark - // (seeded by a drained adoption backfill, or inherited from a - // filter shape that was live before): subscribe first, then fetch - // the gap, so nothing lands between the two. - if let Some(event) = self.backfill_reconnected_source(&source).await? { - self.enqueue_event(event); + fn enqueue_event_with_excluded_owners( + &mut self, + event: SubscriberEvent, + excluded: Option<&HashSet>, + ) { + match event { + SubscriberEvent::Log { source_id, log } => { + if log_matches_any_interest(&log, &self.interests) { + let record = log_input_record(log, InputSource::Subscription); + self.note_log_block(source_id, &record); + self.enqueue_record_with_excluded_owners(record, excluded); + } + } + SubscriberEvent::BackfilledLogs { source_id, logs } => { + self.enqueue_backfilled_logs_with_excluded_owners( + logs, + Some(source_id), + None, + None, + excluded, + ); + } + SubscriberEvent::Logs(logs) => { + for log in logs { + if log_matches_any_interest(&log, &self.interests) { + self.enqueue_record_with_excluded_owners( + log_input_record(log, InputSource::Poll), + excluded, + ); + } + } } - connected.push((source, stream)); - } - - match &mut self.state { - AlloySubscriberState::Active(streams) => { - streams.retain_sources(&desired); - for (source, stream) in connected { - streams.push(source, stream); + SubscriberEvent::BlockHeader(header) => { + if needs_header_block_stream(&self.interests) { + let record = block_header_input_record::(header); + self.enqueue_record_with_excluded_owners(record, excluded); } - if streams.is_empty() { - self.state = AlloySubscriberState::Empty; + } + SubscriberEvent::PendingHash(hash) => { + let record = pending_hash_input_record::(hash, InputSource::Subscription); + self.enqueue_record_with_excluded_owners(record, excluded); + } + SubscriberEvent::PendingHashes(hashes) => { + for hash in hashes { + self.enqueue_record_with_excluded_owners( + pending_hash_input_record::(hash, InputSource::Poll), + excluded, + ); } } - AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => { - let mut streams = SubscriberStreams::new(); - for (source, stream) in connected { - streams.push(source, stream); + SubscriberEvent::PreconfirmedLogs { flashblock, logs } => { + for log in logs { + let record = self + .with_chain_id(preconfirmed_log_input_record::(log, flashblock.clone())); + self.push_pending_record(SubscriberInputRecord { + record, + scope: SubscriberInputScope::Preconfirmed, + }); } - self.state = if streams.is_empty() { - AlloySubscriberState::Empty - } else { - AlloySubscriberState::Active(streams) - }; } + SubscriberEvent::BasePendingLog { .. } + | SubscriberEvent::BaseFlashblock(_) + | SubscriberEvent::OpFlashblockTick + | SubscriberEvent::FlashblockObserved => {} + SubscriberEvent::StreamTerminated(_) => {} } + } - self.bump_stream_revision(); - self.sources_dirty = false; - self.retire_unreferenced_filters(); - Ok(()) + fn enqueue_backfilled_logs( + &mut self, + logs: Vec, + source_id: Option, + owner: Option<&SubscriberOwnerEpoch>, + range: Option, + ) { + self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None); } - /// Fetch queued adoption/continuity backfills, oldest first. - /// - /// An entry is consumed only after its `get_logs` fetch succeeds — a - /// transient RPC failure surfaces the error and leaves the entry queued for - /// the next poll, so a flaky request cannot silently discard the missed - /// window the backfill exists to close. Open-ended backfills resolve their - /// upper bound to the provider's current head before fetching, and every - /// drained backfill advances the filter's delivery anchor to that bound — - /// even a zero-log window — so the filter is reconnect-protected from then - /// on. Draining pauses as soon as records are ready for delivery; remaining - /// entries stay queued. - async fn drain_pending_backfills(&mut self) -> Result<(), SubscriberError> { - while let Some(queued) = self.pending_backfills.front() { - // Owner was removed while its backfill was queued. - let epoch = queued.epoch.clone(); - let owner_exists = match &epoch { - Some(epoch) => self.interest_owner_state(epoch).is_some(), - None => self.owner_interests(&queued.owner).is_some(), - }; - if !owner_exists { - self.pending_backfills.pop_front(); + fn enqueue_backfilled_logs_with_excluded_owners( + &mut self, + logs: Vec, + source_id: Option, + owner: Option<&SubscriberOwnerEpoch>, + range: Option, + excluded: Option<&HashSet>, + ) { + for log in logs { + if range.as_ref().is_some_and(|range| { + log.block_number.is_some_and(|block| { + block < range.start_block() || range.end_block().is_some_and(|end| block > end) + }) + }) { continue; } - let filter = queued.filter.clone(); - let backfill = queued.backfill; - - let to_block = match backfill.end_block() { - Some(to_block) => to_block, - None => self - .provider - .get_block_number() - .await - .map_err(provider_error)?, + let matches = match owner { + Some(epoch) => self + .owned_interests + .iter() + .find(|entry| entry.epoch.as_ref() == Some(epoch)) + .is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)), + None => log_matches_any_interest(&log, &self.interests), }; - if to_block < backfill.start_block() { - // Anchor already at (or past) the provider head: nothing to - // fetch, and the anchor keeps its current value. - self.pending_backfills.pop_front(); - continue; - } - - let range = filter - .clone() - .from_block(backfill.start_block()) - .to_block(to_block); - let logs = self - .provider - .get_logs(&range) - .await - .map_err(provider_error)?; - - // Fetch succeeded: consume the entry, deliver, and advance the - // anchor through the fetched bound. - self.pending_backfills.pop_front(); - let source_id = self.log_source_id(&filter); - self.enqueue_backfilled_logs(logs, Some(source_id), epoch.as_ref(), Some(backfill)); - if epoch.is_none() { - let anchor = self - .last_seen_log_blocks - .entry(source_id) - .or_insert(to_block); - *anchor = (*anchor).max(to_block); - } - - if !self.pending_records.is_empty() { - break; + if matches { + let record = log_input_record(log, InputSource::Backfill); + if let Some(epoch) = owner { + self.enqueue_owner_record(record, epoch.clone()); + } else { + if let Some(source_id) = source_id { + self.note_log_block(source_id, &record); + } + self.enqueue_record_with_excluded_owners(record, excluded); + } } } - Ok(()) - } - - fn stream_sources(&mut self) -> Result, SubscriberError> { - match resolve_subscriber_transport(self.mode)? { - SubscriberTransport::PubSub => Ok(self.pubsub_stream_sources()), - SubscriberTransport::Polling => Ok(self.polling_stream_sources()), - } } - fn pubsub_stream_sources(&mut self) -> Vec { - let mut sources = Vec::new(); - let inherited_anchor = self.last_seen_log_blocks.values().copied().min(); - - for filter in self.log_stream_filters() { - let id = self.log_source_id(&filter); - if let Some(anchor) = inherited_anchor { - self.last_seen_log_blocks.entry(id).or_insert(anchor); + fn enqueue_compat_owner_backfilled_logs( + &mut self, + logs: Vec, + owner: &HandlerId, + range: SubscriberBackfill, + ) { + let interests = self + .owned_interests + .iter() + .find(|entry| { + &entry.owner == owner + && entry.epoch.is_none() + && entry.state == SubscriberOwnerState::Active + }) + .map(|entry| entry.interests.clone()); + let Some(interests) = interests else { + return; + }; + for log in logs { + if log.block_number.is_some_and(|block| { + block < range.start_block() || range.end_block().is_some_and(|end| block > end) + }) || !log_matches_any_interest(&log, &interests) + { + continue; } - sources.push(SubscriberStreamSource::PubSubLog { id, filter }); - } - - if needs_pending_hash_stream(&self.interests) { - sources.push(SubscriberStreamSource::PubSubPendingHashes); - } - - if needs_header_block_stream(&self.interests) { - sources.push(SubscriberStreamSource::PubSubBlockHeaders); + let record = log_input_record(log, InputSource::Backfill); + self.enqueue_compat_owner_record(record, owner.clone()); } - - sources } - fn polling_stream_sources(&self) -> Vec { - let mut sources = Vec::new(); - - for filter in self.log_stream_filters() { - sources.push(SubscriberStreamSource::PollingLog { filter }); - } - - if needs_pending_hash_stream(&self.interests) { - sources.push(SubscriberStreamSource::PollingPendingHashes); + async fn reconnect_source_stream( + &mut self, + source: SubscriberStreamSource, + ) -> Result>, SubscriberError> { + if !source.is_pubsub() { + return Err(stream_terminated_error(&source)); } - sources - } - - fn log_source_id(&mut self, filter: &Filter) -> usize { - if let Some(id) = self.log_source_ids.get(filter) { - return *id; + if !self.config.reconnect.enabled { + return Err(SubscriberError::Provider(format!( + "Alloy subscriber {} stream terminated and reconnect is disabled", + source.label() + ))); } - let id = self.next_log_source_id; - self.next_log_source_id = self.next_log_source_id.saturating_add(1); - self.log_source_ids.insert(filter.clone(), id); - id - } + let mut attempts = 0usize; + let mut delay = self.config.reconnect.initial_delay; + let mut retry_delay = self.config.reconnect.retry_delay; - async fn connect_source_stream( - &mut self, - source: SubscriberStreamSource, - ) -> Result>, SubscriberError> { - match source { - SubscriberStreamSource::PubSubLog { id, filter } => { - self.connect_pubsub_log_stream(id, filter).await - } - SubscriberStreamSource::PubSubPendingHashes => { - self.connect_pubsub_pending_hash_stream().await - } - SubscriberStreamSource::PubSubBlockHeaders => { - self.connect_pubsub_block_header_stream().await - } - SubscriberStreamSource::PollingLog { filter } => { - self.connect_polling_log_stream(filter).await - } - SubscriberStreamSource::PollingPendingHashes => { - self.connect_polling_pending_hash_stream().await + loop { + attempts = attempts.saturating_add(1); + if !delay.is_zero() { + tokio::time::sleep(delay).await; } - } - } - - async fn connect_pubsub_log_stream( - &mut self, - id: usize, - filter: Filter, - ) -> Result>, SubscriberError> { - #[cfg(feature = "reactive-ws")] - { - let source = SubscriberStreamSource::PubSubLog { - id, - filter: filter.clone(), - }; - let stream = self - .provider - .subscribe_logs(&filter) - .channel_size(self.config.max_batch_size.max(1)) - .await - .map_err(provider_error)? - .into_stream() - .map(move |log| SubscriberEvent::Log { source_id: id, log }); - Ok(stream_with_termination(stream, source)) - } - #[cfg(not(feature = "reactive-ws"))] - { - let _ = (id, filter); - Err(SubscriberError::Unsupported( - "AlloySubscriber pubsub mode requires the reactive-ws feature", - )) + match self.reconnect_source_once(source.clone()).await { + Ok(backfill_event) => return Ok(backfill_event), + Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => { + return Err(SubscriberError::Provider(format!( + "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}", + source.label() + ))); + } + Err(error) => { + tracing::warn!( + stream = source.label(), + attempts, + error = %error, + "Alloy subscriber reconnect attempt failed" + ); + delay = retry_delay; + retry_delay = + next_reconnect_delay(retry_delay, self.config.reconnect.max_delay); + } + } } } - async fn connect_pubsub_pending_hash_stream( + async fn reconnect_source_once( &mut self, - ) -> Result>, SubscriberError> { - #[cfg(feature = "reactive-ws")] - { - let stream = self - .provider - .subscribe_pending_transactions() - .channel_size(self.config.max_batch_size.max(1)) - .await - .map_err(provider_error)? - .into_stream() - .map(SubscriberEvent::PendingHash); - Ok(stream_with_termination( - stream, - SubscriberStreamSource::PubSubPendingHashes, - )) + source: SubscriberStreamSource, + ) -> Result>, SubscriberError> { + if matches!( + &self.state, + AlloySubscriberState::Active(streams) if streams.contains_source(&source) + ) { + // A prior attempt installed the stream before its catch-up await + // failed or was cancelled. Retry only the unfinished historical + // window; reconnecting again would create a duplicate live source. + let backfill_event = self.backfill_reconnected_source(&source).await?; + self.pending_source_backfills + .retain(|pending| !pending.same_key(&source)); + return Ok(backfill_event); } - - #[cfg(not(feature = "reactive-ws"))] - { - Err(SubscriberError::Unsupported( - "AlloySubscriber pubsub mode requires the reactive-ws feature", - )) + let stream = self.connect_source_stream(source.clone()).await?; + if !matches!(self.state, AlloySubscriberState::Active(_)) { + return Err(SubscriberError::Provider( + "Alloy subscriber state changed before reconnect completed".to_owned(), + )); } + self.install_source_stream(source.clone(), stream); + if self.source_requires_backfill(&source) { + self.queue_source_backfill(source.clone()); + } + let backfill_event = self.backfill_reconnected_source(&source).await?; + self.pending_source_backfills + .retain(|pending| !pending.same_key(&source)); + + Ok(backfill_event) } - async fn connect_pubsub_block_header_stream( + async fn backfill_reconnected_source( &mut self, - ) -> Result>, SubscriberError> { - #[cfg(feature = "reactive-ws")] - { - let stream = self - .provider - .subscribe_blocks() - .channel_size(self.config.max_batch_size.max(1)) - .await - .map_err(provider_error)? - .into_stream() - .map(SubscriberEvent::BlockHeader); - Ok(stream_with_termination( - stream, - SubscriberStreamSource::PubSubBlockHeaders, - )) + source: &SubscriberStreamSource, + ) -> Result>, SubscriberError> { + if source.is_flashblocks() { + return self.fetch_pending_flashblock().await; } + let SubscriberStreamSource::PubSubLog { id, filter } = source else { + return Ok(None); + }; + let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else { + return Ok(None); + }; - #[cfg(not(feature = "reactive-ws"))] - { - Err(SubscriberError::Unsupported( - "AlloySubscriber pubsub mode requires the reactive-ws feature", - )) + let latest = self + .provider + .get_block_number() + .await + .map_err(provider_error)?; + if latest < from_block { + return Ok(None); + } + + let logs = self + .provider + .get_logs(&filter.clone().from_block(from_block).to_block(latest)) + .await + .map_err(provider_error)?; + Ok(Some(SubscriberEvent::BackfilledLogs { + source_id: *id, + logs, + })) + } + + fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord) { + if let Some(block) = record.context.block.as_ref() { + self.last_seen_log_blocks.insert(source_id, block.number); } } - async fn connect_polling_log_stream( + fn enqueue_record_with_excluded_owners( &mut self, - filter: Filter, - ) -> Result>, SubscriberError> { - #[cfg(feature = "reactive-polling")] - { - let source = SubscriberStreamSource::PollingLog { - filter: filter.clone(), - }; - let stream = self - .provider - .watch_logs(&filter) - .await - .map_err(provider_error)? - .with_channel_size(self.config.max_batch_size.max(1)) - .into_stream() - .map(SubscriberEvent::Logs); - Ok(stream_with_termination(stream, source)) + record: ReactiveInputRecord, + excluded: Option<&HashSet>, + ) { + let record = self.with_chain_id(record); + let mut owners = self.staged_owners_for_record(&record); + if let Some(excluded) = excluded { + owners.retain(|owner| !excluded.contains(owner)); + } + let canonical_duplicate = self.should_skip_recent_duplicate(&record); + let owners = self.filter_recent_owner_duplicates(&record, owners); + let compatibility_owners = self.compatibility_owners_for_record(&record); + let (already_served, newly_served): (Vec<_>, Vec<_>) = compatibility_owners + .into_iter() + .partition(|owner| self.compatibility_owner_has_seen(&record, owner)); + if canonical_duplicate { + if !owners.is_empty() { + self.push_pending_record(SubscriberInputRecord { + record: record.clone(), + scope: SubscriberInputScope::OwnerOnly { owners }, + }); + } + if !newly_served.is_empty() { + for owner in &newly_served { + self.remember_compatibility_owner_record(&record, owner); + } + self.push_pending_record(SubscriberInputRecord { + record, + scope: SubscriberInputScope::OwnerOnlyHandlers { + owners: newly_served, + }, + }); + } + return; + } + self.remember_record(&record); + for owner in already_served.iter().chain(&newly_served) { + self.remember_compatibility_owner_record(&record, owner); } + self.push_pending_record(SubscriberInputRecord { + record, + scope: if already_served.is_empty() { + SubscriberInputScope::Canonical { owners } + } else { + SubscriberInputScope::CanonicalResidual { + owners, + excluded: already_served, + } + }, + }); + } - #[cfg(not(feature = "reactive-polling"))] - { - let _ = filter; - Err(SubscriberError::Unsupported( - "AlloySubscriber polling mode requires the reactive-polling feature", - )) + fn enqueue_compat_owner_record(&mut self, record: ReactiveInputRecord, owner: HandlerId) { + let record = self.with_chain_id(record); + if self.compatibility_owner_has_seen(&record, &owner) { + return; } + self.remember_compatibility_owner_record(&record, &owner); + self.push_pending_record(SubscriberInputRecord { + record, + scope: SubscriberInputScope::OwnerOnlyHandlers { + owners: vec![owner], + }, + }); } - async fn connect_polling_pending_hash_stream( + fn compatibility_owners_for_record(&self, record: &ReactiveInputRecord) -> Vec { + self.owned_interests + .iter() + .filter(|entry| entry.epoch.is_none() && entry.state == SubscriberOwnerState::Active) + .filter(|entry| { + entry + .interests + .iter() + .any(|interest| interest_matches(interest, &record.input)) + }) + .map(|entry| entry.owner.clone()) + .collect() + } + + fn compatibility_owner_has_seen( + &self, + record: &ReactiveInputRecord, + owner: &HandlerId, + ) -> bool { + should_dedupe_record(record) + && self + .recent_compat_owner_input_ref_sets + .get(owner) + .is_some_and(|seen| seen.contains(&record.input_ref())) + } + + fn remember_compatibility_owner_record( &mut self, - ) -> Result>, SubscriberError> { - #[cfg(feature = "reactive-polling")] - { - let stream = self - .provider - .watch_pending_transactions() - .await - .map_err(provider_error)? - .with_channel_size(self.config.max_batch_size.max(1)) - .into_stream() - .map(SubscriberEvent::PendingHashes); - Ok(stream_with_termination( - stream, - SubscriberStreamSource::PollingPendingHashes, - )) + record: &ReactiveInputRecord, + owner: &HandlerId, + ) { + if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 { + return; } - - #[cfg(not(feature = "reactive-polling"))] - { - Err(SubscriberError::Unsupported( - "AlloySubscriber polling mode requires the reactive-polling feature", - )) + let input_ref = record.input_ref(); + let seen = self + .recent_compat_owner_input_ref_sets + .entry(owner.clone()) + .or_default(); + if !seen.insert(input_ref) { + return; + } + let recent = self + .recent_compat_owner_input_refs + .entry(owner.clone()) + .or_default(); + recent.push_back(input_ref); + while recent.len() > self.config.reconnect.dedupe_window { + if let Some(evicted) = recent.pop_front() { + seen.remove(&evicted); + } } } - async fn next_event(&mut self) -> Result>, SubscriberError> { - loop { - let event = match &mut self.state { - AlloySubscriberState::Active(streams) => streams.next().await, - AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => { - return Ok(None); - } - }; + fn enqueue_owner_record( + &mut self, + record: ReactiveInputRecord, + owner: SubscriberOwnerEpoch, + ) { + self.enqueue_owner_record_for_owners(record, vec![owner]); + } - let Some(event) = event else { - return Err(SubscriberError::Provider( - "Alloy subscriber streams terminated before the subscriber was stopped" - .to_owned(), - )); - }; + fn enqueue_owner_record_for_owners( + &mut self, + record: ReactiveInputRecord, + owners: Vec, + ) { + self.enqueue_owner_record_for_owners_inner(record, owners, true); + } - match event { - SubscriberEvent::StreamTerminated(source) => { - // Persist the missing-source intent before the first await. - // If a control command cancels this poll during reconnect, - // the next poll will reconcile the desired/live diff. - self.sources_dirty = true; - self.bump_stream_revision(); - if let Some(backfill_event) = self.reconnect_source_stream(source).await? { - self.sources_dirty = false; - return Ok(Some(backfill_event)); + fn enqueue_owner_record_for_owners_unmerged( + &mut self, + record: ReactiveInputRecord, + owners: Vec, + ) { + self.enqueue_owner_record_for_owners_inner(record, owners, false); + } + + fn enqueue_owner_record_for_owners_inner( + &mut self, + record: ReactiveInputRecord, + owners: Vec, + merge_pending: bool, + ) { + let record = self.with_chain_id(record); + let owners = self.filter_recent_owner_duplicates(&record, owners); + if owners.is_empty() { + return; + } + if merge_pending + && should_dedupe_record(&record) + && self.config.reconnect.dedupe_window != 0 + { + let input_ref = record.input_ref(); + if let Some(pending) = self + .pending_records + .iter_mut() + .rev() + .find(|pending| pending.record.input_ref() == input_ref) + { + let pending_owners = match &mut pending.scope { + SubscriberInputScope::Canonical { owners } + | SubscriberInputScope::CanonicalResidual { owners, .. } + | SubscriberInputScope::OwnerOnly { owners } => Some(owners), + SubscriberInputScope::OwnerOnlyHandlers { .. } + | SubscriberInputScope::Preconfirmed => None, + }; + if let Some(pending_owners) = pending_owners { + for owner in owners { + if !pending_owners.contains(&owner) { + pending_owners.push(owner); + } } - self.sources_dirty = false; + return; } - event => return Ok(Some(event)), } } + self.push_pending_record(SubscriberInputRecord { + record, + scope: SubscriberInputScope::OwnerOnly { owners }, + }); } - fn enqueue_event(&mut self, event: SubscriberEvent) { - self.enqueue_event_with_excluded_owners(event, None); + fn push_pending_record(&mut self, record: SubscriberInputRecord) { + if self.pending_record_count() >= self.config.max_pending_records { + self.note_resource_error(format!( + "pending record queues reached the configured limit of {}", + self.config.max_pending_records + )); + return; + } + self.pending_records.push_back(record); } - fn buffer_reconcile_event_for_owners( + fn ensure_pending_record_capacity( &mut self, - event: &SubscriberEvent, - target_epochs: &HashSet, - ) { - match event { - SubscriberEvent::Log { log, .. } => { - self.buffer_reconcile_log_for_owners(log, InputSource::Subscription, target_epochs) - } - SubscriberEvent::BackfilledLogs { logs, .. } => { - for log in logs { - self.buffer_reconcile_log_for_owners(log, InputSource::Backfill, target_epochs); - } - } - SubscriberEvent::Logs(logs) => { - for log in logs { - self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs); - } - } - SubscriberEvent::BlockHeader(_) - | SubscriberEvent::PendingHash(_) - | SubscriberEvent::PendingHashes(_) - | SubscriberEvent::StreamTerminated(_) => {} + additional: usize, + operation: &str, + ) -> Result<(), SubscriberError> { + let required = self.pending_record_count().saturating_add(additional); + if required > self.config.max_pending_records { + self.note_resource_error(format!( + "{operation} require {required} pending records, above the configured limit of {}", + self.config.max_pending_records + )); + return self.check_resource_error(); } + Ok(()) } - fn buffer_reconcile_log_for_owners( - &mut self, - log: &Log, - source: InputSource, - target_epochs: &HashSet, - ) { - let record = log_input_record(log.clone(), source); - let owners = self - .staged_owners_for_record(&record) - .into_iter() - .filter(|owner| target_epochs.contains(owner)) - .collect::>(); - if !owners.is_empty() { - self.pending_reconcile_owner_records - .push_back(BufferedSubscriberOwnerRecord { record, owners }); + fn push_pending_reconcile_record(&mut self, record: BufferedSubscriberOwnerRecord) { + if self.pending_record_count() >= self.config.max_pending_records { + self.note_resource_error(format!( + "pending record queues reached the configured limit of {}", + self.config.max_pending_records + )); + return; } + self.pending_reconcile_owner_records.push_back(record); } - fn promote_reconcile_owner_records(&mut self, target_epochs: &HashSet) { - let mut retained = VecDeque::new(); - while let Some(mut buffered) = self.pending_reconcile_owner_records.pop_front() { - let mut promoted = Vec::new(); - buffered.owners.retain(|owner| { - if target_epochs.contains(owner) { - promoted.push(owner.clone()); - false - } else { - true - } - }); - if promoted.is_empty() { - retained.push_back(buffered); - continue; - } - let promoted_record = if buffered.owners.is_empty() { - buffered.record - } else { - let record = buffered.record.clone(); - retained.push_back(buffered); - record - }; - self.enqueue_owner_record_for_owners_unmerged(promoted_record, promoted); + fn pending_record_count(&self) -> usize { + self.pending_records + .len() + .saturating_add(self.pending_reconcile_owner_records.len()) + } + + fn note_resource_error(&mut self, message: String) { + if self.resource_error.is_none() { + self.resource_error = Some(message); } - self.pending_reconcile_owner_records = retained; } - fn seed_reconciled_filter_anchors( - &mut self, - plans: &[SubscriberOwnerReconcilePlan], - through: u64, - ) { - for filter in plans.iter().flat_map(|plan| log_filters(&plan.interests)) { - let Some(source_id) = self.log_source_ids.get(&filter).copied() else { - continue; + fn check_resource_error(&self) -> Result<(), SubscriberError> { + match &self.resource_error { + Some(message) => Err(SubscriberError::ResourceExhausted(message.clone())), + None => Ok(()), + } + } + + fn with_chain_id(&self, mut record: ReactiveInputRecord) -> ReactiveInputRecord { + record.context.chain_id = self.chain_id; + if self.config.verify_log_block_context + && let ReactiveInput::Log(log) = &record.input + && !log.removed + && let (Some(number), Some(hash)) = (log.block_number, log.block_hash) + && let Some(verified) = self.verified_log_blocks.get(&(number, hash)).copied() + { + record.context.block = Some(verified); + record.context.chain_status = ChainStatus::Included { + block: verified, + confirmations: 0, }; - let anchor = self - .last_seen_log_blocks - .entry(source_id) - .or_insert(through); - *anchor = (*anchor).max(through); } + record } - fn enqueue_event_excluding_owners( - &mut self, - event: SubscriberEvent, - excluded: &HashSet, - ) { - self.enqueue_event_with_excluded_owners(event, Some(excluded)); + fn staged_owners_for_record( + &self, + record: &ReactiveInputRecord, + ) -> Vec { + self.owned_interests + .iter() + .filter(|entry| entry.state == SubscriberOwnerState::Staged) + .filter(|entry| { + entry + .interests + .iter() + .any(|interest| interest_matches(interest, &record.input)) + }) + .filter_map(|entry| entry.epoch.clone()) + .collect() } - fn enqueue_event_with_excluded_owners( + fn filter_recent_owner_duplicates( &mut self, - event: SubscriberEvent, - excluded: Option<&HashSet>, - ) { - match event { - SubscriberEvent::Log { source_id, log } => { - if log_matches_any_interest(&log, &self.interests) { - let record = log_input_record(log, InputSource::Subscription); - self.note_log_block(source_id, &record); - self.enqueue_record_with_excluded_owners(record, excluded); + record: &ReactiveInputRecord, + owners: Vec, + ) -> Vec { + if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 { + return owners; + } + let input_ref = record.input_ref(); + let window = self.config.reconnect.dedupe_window; + owners + .into_iter() + .filter(|owner| { + let seen = self + .recent_owner_input_ref_sets + .entry(owner.clone()) + .or_default(); + if !seen.insert(input_ref) { + return false; } - } - SubscriberEvent::BackfilledLogs { source_id, logs } => { - self.enqueue_backfilled_logs_with_excluded_owners( - logs, - Some(source_id), - None, - None, - excluded, - ); - } - SubscriberEvent::Logs(logs) => { - for log in logs { - if log_matches_any_interest(&log, &self.interests) { - self.enqueue_record_with_excluded_owners( - log_input_record(log, InputSource::Poll), - excluded, - ); + let recent = self + .recent_owner_input_refs + .entry(owner.clone()) + .or_default(); + recent.push_back(input_ref); + while recent.len() > window { + if let Some(evicted) = recent.pop_front() { + seen.remove(&evicted); } } - } - SubscriberEvent::BlockHeader(header) => { - if needs_header_block_stream(&self.interests) { - let record = block_header_input_record::(header); - self.enqueue_record_with_excluded_owners(record, excluded); - } - } - SubscriberEvent::PendingHash(hash) => { - let record = pending_hash_input_record::(hash, InputSource::Subscription); - self.enqueue_record_with_excluded_owners(record, excluded); - } - SubscriberEvent::PendingHashes(hashes) => { - for hash in hashes { - self.enqueue_record_with_excluded_owners( - pending_hash_input_record::(hash, InputSource::Poll), - excluded, - ); - } - } - SubscriberEvent::StreamTerminated(_) => {} - } + true + }) + .collect() } - fn enqueue_backfilled_logs( - &mut self, - logs: Vec, - source_id: Option, - owner: Option<&SubscriberOwnerEpoch>, - range: Option, - ) { - self.enqueue_backfilled_logs_with_excluded_owners(logs, source_id, owner, range, None); + fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord) -> bool { + if !should_dedupe_record(record) { + return false; + } + self.recent_input_ref_set.contains(&record.input_ref()) } - fn enqueue_backfilled_logs_with_excluded_owners( - &mut self, - logs: Vec, - source_id: Option, - owner: Option<&SubscriberOwnerEpoch>, - range: Option, - excluded: Option<&HashSet>, - ) { - for log in logs { - if range.is_some_and(|range| { - log.block_number.is_some_and(|block| { - block < range.start_block() || range.end_block().is_some_and(|end| block > end) - }) - }) { - continue; - } - let matches = match owner { - Some(epoch) => self - .owned_interests - .iter() - .find(|entry| entry.epoch.as_ref() == Some(epoch)) - .is_some_and(|entry| log_matches_any_interest(&log, &entry.interests)), - None => log_matches_any_interest(&log, &self.interests), - }; - if matches { - let record = log_input_record(log, InputSource::Backfill); - if let Some(epoch) = owner { - self.enqueue_owner_record(record, epoch.clone()); - } else { - if let Some(source_id) = source_id { - self.note_log_block(source_id, &record); - } - self.enqueue_record_with_excluded_owners(record, excluded); - } + fn remember_record(&mut self, record: &ReactiveInputRecord) { + if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 { + return; + } + + let input_ref = record.input_ref(); + if !self.recent_input_ref_set.insert(input_ref) { + return; + } + self.recent_input_refs.push_back(input_ref); + + while self.recent_input_refs.len() > self.config.reconnect.dedupe_window { + if let Some(evicted) = self.recent_input_refs.pop_front() { + self.recent_input_ref_set.remove(&evicted); } } } +} - async fn reconnect_source_stream( - &mut self, - source: SubscriberStreamSource, - ) -> Result>, SubscriberError> { - if !source.is_pubsub() { - return Err(stream_terminated_error(&source)); - } +fn stream_with_termination( + stream: S, + source: SubscriberStreamSource, +) -> BoxStream<'static, SubscriberEvent> +where + N: Network + 'static, + S: futures::Stream> + Send + 'static, +{ + stream + .chain(stream::once(async move { + SubscriberEvent::StreamTerminated(source) + })) + .boxed() +} - if !self.config.reconnect.enabled { - return Err(SubscriberError::Provider(format!( - "Alloy subscriber {} stream terminated and reconnect is disabled", - source.label() - ))); - } +fn aggregate_interests( + base: &[ReactiveInterest], + owned: &[OwnedSubscriberInterests], +) -> Vec> { + base.iter() + .cloned() + .chain( + owned + .iter() + .flat_map(|entry| entry.interests.iter().cloned()), + ) + .collect() +} - let mut attempts = 0usize; - let mut delay = self.config.reconnect.initial_delay; - let mut retry_delay = self.config.reconnect.retry_delay; +fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError { + SubscriberError::Provider(format!( + "Alloy subscriber {} stream terminated before the subscriber was stopped", + source.label() + )) +} - loop { - attempts = attempts.saturating_add(1); - if !delay.is_zero() { - tokio::time::sleep(delay).await; - } +fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool { + config + .max_attempts + .is_some_and(|max_attempts| attempts >= max_attempts) +} - match self.reconnect_source_once(source.clone()).await { - Ok(backfill_event) => return Ok(backfill_event), - Err(error) if reconnect_attempts_exhausted(attempts, &self.config.reconnect) => { - return Err(SubscriberError::Provider(format!( - "Alloy subscriber {} stream terminated and reconnect failed after {attempts} attempt(s): {error}", - source.label() - ))); - } - Err(error) => { - tracing::warn!( - stream = source.label(), - attempts, - error = %error, - "Alloy subscriber reconnect attempt failed" - ); - delay = retry_delay; - retry_delay = - next_reconnect_delay(retry_delay, self.config.reconnect.max_delay); - } - } - } +fn next_reconnect_delay(current: Duration, max: Duration) -> Duration { + if current.is_zero() { + return current; } + current.checked_mul(2).unwrap_or(max).min(max) +} - async fn reconnect_source_once( - &mut self, - source: SubscriberStreamSource, - ) -> Result>, SubscriberError> { - let stream = self.connect_source_stream(source.clone()).await?; - let backfill_event = self.backfill_reconnected_source(&source).await?; - - match &mut self.state { - AlloySubscriberState::Active(streams) => streams.push(source, stream), - AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => { - return Err(SubscriberError::Provider( - "Alloy subscriber state changed before reconnect completed".to_owned(), - )); - } +fn should_dedupe_record(record: &ReactiveInputRecord) -> bool { + match &record.input { + ReactiveInput::Log(log) => { + is_canonical_status(&record.context.chain_status) && !log.removed } + ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true, + ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false, + } +} - Ok(backfill_event) +#[cfg(test)] +mod subscriber_helper_tests { + use super::*; + use alloy_provider::ProviderBuilder; + use alloy_transport::mock::Asserter; + + #[test] + fn base_flashblock_wire_decodes_cumulative_block_shape() { + let payload: BaseFlashblockWirePayload = serde_json::from_str( + r#"{ + "hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "number":"0x2ef403b", + "parentHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "stateRoot":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "timestamp":"0x6a68dd59", + "transactions":[] + }"#, + ) + .expect("decode current Base newFlashblocks shape"); + let BaseFlashblockWirePayload::Block(payload) = payload else { + panic!("expected cumulative block-shaped payload") + }; + assert_eq!(payload.number, 49_233_979); + assert_eq!(payload.timestamp, 1_785_257_305); + assert_eq!(payload.hash, B256::repeat_byte(0xaa)); + assert_eq!(payload.parent_hash, B256::repeat_byte(0xbb)); + assert_eq!(payload.state_root, B256::repeat_byte(0xcc)); } - async fn backfill_reconnected_source( - &mut self, - source: &SubscriberStreamSource, - ) -> Result>, SubscriberError> { - let SubscriberStreamSource::PubSubLog { id, filter } = source else { - return Ok(None); + #[test] + fn unproven_parent_replacement_rewind_discards_every_unauthenticated_identity() { + let parent = BlockRef { + number: 79, + hash: B256::repeat_byte(0x79), + parent_hash: Some(B256::repeat_byte(0x78)), + timestamp: Some(1_700_000_079), }; - let Some(from_block) = self.last_seen_log_blocks.get(id).copied() else { - return Ok(None); + let old_tip = BlockRef { + number: 80, + hash: B256::repeat_byte(0x80), + parent_hash: Some(parent.hash), + timestamp: Some(1_700_000_080), }; + let replacement = BlockRef { + hash: B256::repeat_byte(0xe0), + parent_hash: Some(B256::repeat_byte(0xdf)), + ..old_tip + }; + let mut state = + CanonicalSequenceState::new(vec![parent, old_tip], Some(old_tip), Some(parent), None); - let latest = self - .provider - .get_block_number() - .await - .map_err(provider_error)?; - if latest < from_block { - return Ok(None); - } + let rewind = apply_sequence_canonical_block(&mut state, &replacement, false) + .expect("replacement metadata is structurally valid") + .expect("unknown parent is an observable rewind"); - let logs = self - .provider - .get_logs(&filter.clone().from_block(from_block).to_block(latest)) - .await - .map_err(provider_error)?; - Ok(Some(SubscriberEvent::BackfilledLogs { - source_id: *id, - logs, - })) + assert_eq!(rewind.common_ancestor, None); + assert_eq!(rewind.dropped, vec![parent, old_tip]); + assert_eq!(state.retained_canonical_history(), &[replacement]); + assert_eq!(state.coverage_head(), Some(&replacement)); + assert_eq!(state.safe_head(), None); + assert_eq!(state.finalized_head(), None); } - fn note_log_block(&mut self, source_id: usize, record: &ReactiveInputRecord) { - if let Some(block) = record.context.block.as_ref() { - self.last_seen_log_blocks.insert(source_id, block.number); + #[test] + fn handler_ids_are_non_empty_across_construction_and_deserialization() { + assert_eq!(HandlerId::try_new("").unwrap_err(), HandlerIdError); + let valid = HandlerId::try_new("owner-1").expect("non-empty id"); + let encoded = serde_json::to_string(&valid).expect("serialize id"); + assert_eq!( + serde_json::from_str::(&encoded).expect("deserialize valid id"), + valid + ); + assert!(serde_json::from_str::(r#"""#).is_err()); + } + + fn rpc_log(removed: bool) -> Log { + Log { + inner: alloy_primitives::Log::new_unchecked( + Address::repeat_byte(0x42), + vec![B256::repeat_byte(0x01)], + Bytes::new(), + ), + block_hash: Some(B256::repeat_byte(0x02)), + block_number: Some(7), + block_timestamp: Some(1_700_000_000), + transaction_hash: Some(B256::repeat_byte(0x03)), + transaction_index: Some(4), + log_index: Some(5), + removed, } } - fn enqueue_record_with_excluded_owners( - &mut self, - record: ReactiveInputRecord, - excluded: Option<&HashSet>, - ) { - let mut owners = self.staged_owners_for_record(&record); - if let Some(excluded) = excluded { - owners.retain(|owner| !excluded.contains(owner)); + fn rpc_transaction(chain_id: Option) -> alloy_rpc_types_eth::Transaction { + use alloy_consensus::SignableTransaction as _; + + let envelope: alloy_consensus::TxEnvelope = alloy_consensus::TxLegacy { + chain_id, + ..Default::default() } - let canonical_duplicate = self.should_skip_recent_duplicate(&record); - let owners = self.filter_recent_owner_duplicates(&record, owners); - if canonical_duplicate { - if !owners.is_empty() { - self.pending_records.push_back(SubscriberInputRecord { - record, - scope: SubscriberInputScope::OwnerOnly { owners }, - }); - } - return; + .into_signed(alloy_primitives::Signature::test_signature()) + .into(); + alloy_rpc_types_eth::Transaction { + inner: alloy_consensus::transaction::Recovered::new_unchecked(envelope, Address::ZERO), + block_hash: None, + block_number: None, + transaction_index: None, + effective_gas_price: None, } - self.remember_record(&record); - self.pending_records.push_back(SubscriberInputRecord { - record, - scope: SubscriberInputScope::Canonical { owners }, - }); } - fn enqueue_owner_record( - &mut self, - record: ReactiveInputRecord, - owner: SubscriberOwnerEpoch, - ) { - self.enqueue_owner_record_for_owners(record, vec![owner]); + #[cfg(feature = "reactive-ws")] + fn rpc_log_at(block_number: u64, transaction_index: u64, log_index: u64) -> Log { + Log { + inner: alloy_primitives::Log::new_unchecked( + Address::repeat_byte(0x42), + vec![B256::repeat_byte(0x01)], + Bytes::new(), + ), + block_hash: Some(B256::repeat_byte(block_number as u8)), + block_number: Some(block_number), + block_timestamp: Some(1_700_000_000 + block_number), + transaction_hash: Some(B256::repeat_byte(0x20 + transaction_index as u8)), + transaction_index: Some(transaction_index), + log_index: Some(log_index), + removed: false, + } + } + + #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] + fn rpc_block(number: u64, hash: B256) -> alloy_rpc_types_eth::Block { + alloy_rpc_types_eth::Block::empty(alloy_rpc_types_eth::Header { + hash, + inner: alloy_consensus::Header { + number, + parent_hash: B256::repeat_byte(number.saturating_sub(1) as u8), + timestamp: 1_700_000_000 + number, + ..Default::default() + }, + total_difficulty: None, + size: None, + }) + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "reactive-ws")] + async fn verified_log_context_fetches_and_caches_exact_parent_identity() { + let stream_asserter = Asserter::new(); + let provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone()); + let verification_asserter = Asserter::new(); + verification_asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(7)))); + let verification_provider = + ProviderBuilder::new().connect_mocked_client(verification_asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + verify_log_block_context: true, + ..SubscriberConfig::default() + }, + ) + .with_log_verification_provider(verification_provider); + let log = rpc_log_at(7, 0, 0); + + subscriber + .verify_log_block_context(&log) + .await + .expect("verify live log block"); + subscriber + .verify_log_block_context(&log) + .await + .expect("reuse verified block cache"); + let record = subscriber.with_chain_id(log_input_record(log, InputSource::Subscription)); + + assert_eq!( + record.context.block.expect("verified block").parent_hash, + Some(B256::repeat_byte(6)) + ); + assert!( + verification_asserter.read_q().is_empty(), + "one provider lookup should verify every log in the same block" + ); + assert!( + stream_asserter.read_q().is_empty(), + "verification must not use the high-volume stream provider" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn stream_with_termination_yields_terminal_source_marker() { + let mut stream = stream_with_termination::( + stream::iter([SubscriberEvent::::PendingHash(B256::repeat_byte( + 0xaa, + ))]), + SubscriberStreamSource::PubSubPendingHashes, + ); + + assert!(matches!( + stream.next().await, + Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa) + )); + assert!(matches!( + stream.next().await, + Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub() + )); + assert!(stream.next().await.is_none()); + } + + #[test] + fn reconnect_delay_doubles_until_capped() { + assert_eq!( + next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)), + Duration::from_millis(500) + ); + assert_eq!( + next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)), + Duration::from_secs(1) + ); + assert_eq!( + next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)), + Duration::ZERO + ); } - fn enqueue_owner_record_for_owners( - &mut self, - record: ReactiveInputRecord, - owners: Vec, - ) { - self.enqueue_owner_record_for_owners_inner(record, owners, true); - } + #[test] + fn canonical_logs_are_deduped_but_removed_logs_are_not() { + let included = log_input_record::(rpc_log(false), InputSource::Subscription); + let removed = log_input_record::(rpc_log(true), InputSource::Subscription); - fn enqueue_owner_record_for_owners_unmerged( - &mut self, - record: ReactiveInputRecord, - owners: Vec, - ) { - self.enqueue_owner_record_for_owners_inner(record, owners, false); + assert!(should_dedupe_record(&included)); + assert!(!should_dedupe_record(&removed)); } - fn enqueue_owner_record_for_owners_inner( - &mut self, - record: ReactiveInputRecord, - owners: Vec, - merge_pending: bool, - ) { - let owners = self.filter_recent_owner_duplicates(&record, owners); - if owners.is_empty() { - return; - } - if merge_pending - && should_dedupe_record(&record) - && self.config.reconnect.dedupe_window != 0 - { - let input_ref = record.input_ref(); - if let Some(pending) = self - .pending_records - .iter_mut() - .rev() - .find(|pending| pending.record.input_ref() == input_ref) - { - let pending_owners = match &mut pending.scope { - SubscriberInputScope::Canonical { owners } - | SubscriberInputScope::OwnerOnly { owners } => owners, - }; - for owner in owners { - if !pending_owners.contains(&owner) { - pending_owners.push(owner); - } + #[test] + fn owner_reconcile_dedupe_rejects_conflicts_and_preserves_compatible_enrichment() { + let set_context_timestamp = |record: &mut ReactiveInputRecord, + timestamp: Option| { + record.context.block.as_mut().expect("block").timestamp = timestamp; + match &mut record.context.chain_status { + ChainStatus::Included { block, .. } + | ChainStatus::Safe { block } + | ChainStatus::Finalized { block } + | ChainStatus::Reorged { + dropped_from: block, + } => block.timestamp = timestamp, + ChainStatus::Pending | ChainStatus::Preconfirmed { .. } => { + panic!("log record is canonical") } - return; } + }; + + let mut payload_only = log_input_record::(rpc_log(false), InputSource::Backfill); + let payload_timestamp = match &payload_only.input { + ReactiveInput::Log(log) => log.block_timestamp.expect("timestamp"), + _ => unreachable!(), + }; + set_context_timestamp(&mut payload_only, None); + let mut context_only = payload_only.clone(); + if let ReactiveInput::Log(log) = &mut context_only.input { + log.block_timestamp = None; } - self.pending_records.push_back(SubscriberInputRecord { - record, - scope: SubscriberInputScope::OwnerOnly { owners }, - }); - } + set_context_timestamp(&mut context_only, Some(payload_timestamp + 1)); + assert!(matches!( + dedupe_records(vec![payload_only, context_only]), + Err(ReactiveError::InvalidInputRecord { .. }) + )); - fn staged_owners_for_record( - &self, - record: &ReactiveInputRecord, - ) -> Vec { - self.owned_interests - .iter() - .filter(|entry| entry.state == SubscriberOwnerState::Staged) - .filter(|entry| { - entry - .interests - .iter() - .any(|interest| interest_matches(interest, &record.input)) - }) - .filter_map(|entry| entry.epoch.clone()) - .collect() + let mut partial = log_input_record::(rpc_log(false), InputSource::Backfill); + if let ReactiveInput::Log(log) = &mut partial.input { + log.block_timestamp = None; + } + set_context_timestamp(&mut partial, None); + let complete = log_input_record::(rpc_log(false), InputSource::Subscription); + let deduped = + dedupe_records(vec![partial, complete]).expect("compatible metadata enriches"); + assert_eq!(deduped.len(), 1); + deduped[0] + .validated_identity() + .expect("merged record remains coherent"); + let resolved = resolve_record_block_payload_metadata( + &deduped[0], + *canonical_record_block(&deduped[0]).expect("canonical"), + ) + .expect("effective block"); + assert_eq!(resolved.timestamp, Some(payload_timestamp)); } - fn filter_recent_owner_duplicates( - &mut self, - record: &ReactiveInputRecord, - owners: Vec, - ) -> Vec { - if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 { - return owners; - } - let input_ref = record.input_ref(); - let window = self.config.reconnect.dedupe_window; - owners - .into_iter() - .filter(|owner| { - let seen = self - .recent_owner_input_ref_sets - .entry(owner.clone()) - .or_default(); - if !seen.insert(input_ref) { - return false; - } - let recent = self - .recent_owner_input_refs - .entry(owner.clone()) - .or_default(); - recent.push_back(input_ref); - while recent.len() > window { - if let Some(evicted) = recent.pop_front() { - seen.remove(&evicted); - } - } - true - }) - .collect() + #[test] + fn full_block_bodies_are_never_suppressed_from_header_hash_alone() { + use alloy_rpc_types_eth::{Block, Header}; + + let block_ref = BlockRef { + number: 7, + hash: B256::repeat_byte(0x77), + parent_hash: Some(B256::repeat_byte(0x66)), + timestamp: Some(1_700_000_007), + }; + let block = Block::empty(Header { + hash: block_ref.hash, + inner: alloy_consensus::Header { + number: block_ref.number, + parent_hash: block_ref.parent_hash.expect("parent"), + timestamp: block_ref.timestamp.expect("timestamp"), + ..Default::default() + }, + total_difficulty: None, + size: None, + }); + let record = ReactiveInputRecord::::new( + ReactiveInput::FullBlock(block), + ReactiveContext { + chain_id: Some(1), + source: InputSource::Subscription, + chain_status: ChainStatus::Included { + block: block_ref, + confirmations: 0, + }, + block: Some(block_ref), + transaction_index: None, + log_index: None, + }, + ); + + assert!(!record.is_payload_deduplicable()); + assert!(!record.same_deduplicable_payload(&record)); + let retained = dedupe_scoped_records(vec![ + ( + record.clone(), + DeliveryAudience::All, + DeliveryScope::Canonical, + ), + (record, DeliveryAudience::All, DeliveryScope::Canonical), + ]) + .expect("non-deduplicable bodies are preserved, not treated as conflicts"); + assert_eq!(retained.len(), 2); } - fn should_skip_recent_duplicate(&self, record: &ReactiveInputRecord) -> bool { - if !should_dedupe_record(record) { - return false; + #[test] + fn hydrated_transaction_wrappers_reject_inclusion_and_chain_identity_conflicts() { + let pending_context = ReactiveContext { + chain_id: Some(1), + source: InputSource::Batch, + chain_status: ChainStatus::Pending, + block: None, + transaction_index: None, + log_index: None, + }; + let mut included_pending = rpc_transaction(Some(1)); + included_pending.block_hash = Some(B256::repeat_byte(0xaa)); + assert!(matches!( + ReactiveInputRecord::::new( + ReactiveInput::PendingTx(included_pending), + pending_context.clone(), + ) + .validated_identity(), + Err(ReactiveError::InvalidInputRecord { .. }) + )); + assert!(matches!( + ReactiveInputRecord::::new( + ReactiveInput::PendingTx(rpc_transaction(Some(2))), + pending_context, + ) + .validated_identity(), + Err(ReactiveError::InvalidInputRecord { .. }) + )); + + let block_ref = BlockRef { + number: 8, + hash: B256::repeat_byte(0x88), + parent_hash: Some(B256::repeat_byte(0x77)), + timestamp: Some(1_700_000_008), + }; + let header = alloy_rpc_types_eth::Header { + hash: block_ref.hash, + inner: alloy_consensus::Header { + number: block_ref.number, + parent_hash: block_ref.parent_hash.expect("parent"), + timestamp: block_ref.timestamp.expect("timestamp"), + ..Default::default() + }, + total_difficulty: None, + size: None, + }; + let context = ReactiveContext { + chain_id: Some(1), + source: InputSource::Batch, + chain_status: ChainStatus::Included { + block: block_ref, + confirmations: 0, + }, + block: Some(block_ref), + transaction_index: None, + log_index: None, + }; + for transaction in [ + alloy_rpc_types_eth::Transaction { + block_hash: Some(B256::repeat_byte(0xff)), + ..rpc_transaction(Some(1)) + }, + alloy_rpc_types_eth::Transaction { + block_hash: Some(block_ref.hash), + block_number: Some(block_ref.number), + transaction_index: Some(1), + ..rpc_transaction(Some(1)) + }, + rpc_transaction(Some(2)), + ] { + let block = alloy_rpc_types_eth::Block::new( + header.clone(), + alloy_network::primitives::BlockTransactions::Full(vec![transaction]), + ); + assert!(matches!( + ReactiveInputRecord::::new( + ReactiveInput::FullBlock(block), + context.clone(), + ) + .validated_identity(), + Err(ReactiveError::InvalidInputRecord { .. }) + )); } - self.recent_input_ref_set.contains(&record.input_ref()) } - fn remember_record(&mut self, record: &ReactiveInputRecord) { - if !should_dedupe_record(record) || self.config.reconnect.dedupe_window == 0 { - return; - } + #[test] + fn compatibility_owner_backfill_and_live_overlap_split_exact_audiences() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig::default(), + ); + let owner = HandlerId::new("compat-owner"); + subscriber + .add_interest_owner( + owner.clone(), + &[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(Address::repeat_byte(0x42)), + local_matcher: None, + route_key: None, + })], + ) + .unwrap(); + let log = rpc_log(false); - let input_ref = record.input_ref(); - if !self.recent_input_ref_set.insert(input_ref) { - return; - } - self.recent_input_refs.push_back(input_ref); + subscriber.enqueue_compat_owner_record( + log_input_record(log.clone(), InputSource::Backfill), + owner.clone(), + ); + subscriber.enqueue_event(SubscriberEvent::Log { source_id: 0, log }); - while self.recent_input_refs.len() > self.config.reconnect.dedupe_window { - if let Some(evicted) = self.recent_input_refs.pop_front() { - self.recent_input_ref_set.remove(&evicted); + let batch = subscriber + .drain_next_scoped_batch() + .expect("owner catch-up and residual live copies"); + assert_eq!(batch.records.len(), 2); + assert_eq!( + batch.records[0].scope, + SubscriberInputScope::OwnerOnlyHandlers { + owners: vec![owner.clone()] } - } - } -} + ); + assert_eq!( + batch.records[1].scope, + SubscriberInputScope::CanonicalResidual { + owners: Vec::new(), + excluded: vec![owner.clone()] + } + ); -#[cfg(any(feature = "reactive-ws", feature = "reactive-polling", test))] -fn stream_with_termination( - stream: S, - source: SubscriberStreamSource, -) -> BoxStream<'static, SubscriberEvent> -where - N: Network + 'static, - S: futures::Stream> + Send + 'static, -{ - stream - .chain(stream::once(async move { - SubscriberEvent::StreamTerminated(source) - })) - .boxed() -} + let reactive = batch.into_reactive_batch(); + assert_eq!( + reactive.record_audience(0), + Some(&DeliveryAudience::Owners(vec![owner.clone()])) + ); + assert_eq!( + reactive.record_delivery_scope(0), + Some(DeliveryScope::OwnerCatchup) + ); + assert_eq!( + reactive.record_audience(1), + Some(&DeliveryAudience::AllExcept(vec![owner])) + ); + assert_eq!( + reactive.record_delivery_scope(1), + Some(DeliveryScope::Canonical) + ); + } -fn aggregate_interests( - base: &[ReactiveInterest], - owned: &[OwnedSubscriberInterests], -) -> Vec> { - base.iter() - .cloned() - .chain( - owned - .iter() - .flat_map(|entry| entry.interests.iter().cloned()), - ) - .collect() -} + #[test] + fn active_owner_replacement_commits_atomically_to_one_new_epoch() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig::default(), + ); + let owner = HandlerId::new("replace-owner"); + let original = ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(Address::repeat_byte(0x41)), + local_matcher: None, + route_key: None, + }); + let replacement_interest = ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(Address::repeat_byte(0x42)), + local_matcher: None, + route_key: None, + }); + let active = subscriber + .stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live) + .unwrap(); + assert!(subscriber.activate_interest_owner(&active)); + let replacement = subscriber + .stage_interest_owner_replacement( + owner, + &[replacement_interest], + SubscriberOwnerStart::Live, + ) + .unwrap(); -fn stream_terminated_error(source: &SubscriberStreamSource) -> SubscriberError { - SubscriberError::Provider(format!( - "Alloy subscriber {} stream terminated before the subscriber was stopped", - source.label() - )) -} + assert!(subscriber.commit_interest_owner_replacement(&active, &replacement)); + assert_eq!(subscriber.interest_owner_state(&active), None); + assert_eq!( + subscriber.interest_owner_state(&replacement), + Some(SubscriberOwnerState::Active) + ); + assert_eq!(subscriber.registered_interests().len(), 1); + } -fn reconnect_attempts_exhausted(attempts: usize, config: &SubscriberReconnectConfig) -> bool { - config - .max_attempts - .is_some_and(|max_attempts| attempts >= max_attempts) -} + #[test] + fn compatibility_and_epoch_owner_lifecycles_cannot_mix() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig::default(), + ); + let owner = HandlerId::new("one-lifecycle"); + let interest = ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(Address::repeat_byte(0x42)), + local_matcher: None, + route_key: None, + }); + let epoch = subscriber + .stage_interest_owner( + owner.clone(), + std::slice::from_ref(&interest), + SubscriberOwnerStart::Live, + ) + .expect("stage epoch owner"); -fn next_reconnect_delay(current: Duration, max: Duration) -> Duration { - if current.is_zero() { - return current; + assert!(matches!( + subscriber.add_interest_owner(owner.clone(), std::slice::from_ref(&interest)), + Err(SubscriberError::InvalidConfig(_)) + )); + assert_eq!( + subscriber.interest_owner_state(&epoch), + Some(SubscriberOwnerState::Staged) + ); + assert!(subscriber.abort_interest_owner(&epoch)); + subscriber + .add_interest_owner(owner.clone(), std::slice::from_ref(&interest)) + .expect("compatibility owner after epoch abort"); + assert!(matches!( + subscriber.stage_interest_owner_replacement( + owner, + std::slice::from_ref(&interest), + SubscriberOwnerStart::Live, + ), + Err(SubscriberOwnerError::AlreadyRegistered(_)) + )); } - current.checked_mul(2).unwrap_or(max).min(max) -} -fn should_dedupe_record(record: &ReactiveInputRecord) -> bool { - match &record.input { - ReactiveInput::Log(log) => { - is_canonical_status(&record.context.chain_status) && !log.removed - } - ReactiveInput::BlockHeader(_) | ReactiveInput::PendingTxHash(_) => true, - ReactiveInput::FullBlock(_) | ReactiveInput::PendingTx(_) => false, + #[test] + fn pending_record_overflow_is_sticky_and_fail_closed() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Polling, + SubscriberConfig { + max_pending_records: 1, + ..SubscriberConfig::default() + }, + ); + subscriber.interests = vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(Address::repeat_byte(0x42)), + local_matcher: None, + route_key: None, + })]; + subscriber.enqueue_event(SubscriberEvent::Log { + source_id: 0, + log: rpc_log(false), + }); + let mut second = rpc_log(false); + second.log_index = Some(6); + second.transaction_hash = Some(B256::repeat_byte(0x04)); + subscriber.enqueue_event(SubscriberEvent::Log { + source_id: 0, + log: second, + }); + + assert_eq!(subscriber.pending_records.len(), 1); + assert!(matches!( + subscriber.check_resource_error(), + Err(SubscriberError::ResourceExhausted(_)) + )); + subscriber.reset_delivery_state(); + assert!(subscriber.check_resource_error().is_ok()); } -} -#[cfg(test)] -mod subscriber_helper_tests { - use super::*; - use alloy_provider::ProviderBuilder; - use alloy_transport::mock::Asserter; + #[test] + fn historical_log_payload_bytes_are_bounded_independently_of_log_count() { + let baseline = rpc_log(false); + let fixed_bytes = + validate_backfill_resource_limits(std::slice::from_ref(&baseline), 1, usize::MAX) + .expect("measure fixed log accounting"); + let mut large = baseline; + large.inner = alloy_primitives::Log::new_unchecked( + Address::repeat_byte(0x42), + vec![B256::repeat_byte(0x01)], + Bytes::from(vec![0u8; 256]), + ); - fn rpc_log(removed: bool) -> Log { - Log { - inner: alloy_primitives::Log::new_unchecked( - Address::repeat_byte(0x42), - vec![B256::repeat_byte(0x01)], - Bytes::new(), - ), - block_hash: Some(B256::repeat_byte(0x02)), - block_number: Some(7), - block_timestamp: Some(1_700_000_000), - transaction_hash: Some(B256::repeat_byte(0x03)), - transaction_index: Some(4), - log_index: Some(5), - removed, - } + assert!(matches!( + validate_backfill_resource_limits(&[large], 1, fixed_bytes + 255), + Err(SubscriberError::ResourceExhausted(_)) + )); } #[tokio::test(flavor = "multi_thread")] - async fn stream_with_termination_yields_terminal_source_marker() { - let mut stream = stream_with_termination::( - stream::iter([SubscriberEvent::::PendingHash(B256::repeat_byte( - 0xaa, - ))]), - SubscriberStreamSource::PubSubPendingHashes, + #[cfg(feature = "reactive-polling")] + async fn reconcile_capacity_failure_does_not_publish_progress_or_partial_history() { + use alloy_rpc_types_eth::{Block, Header}; + + let asserter = Asserter::new(); + let baseline = BlockRef { + number: 6, + hash: B256::repeat_byte(6), + parent_hash: Some(B256::repeat_byte(5)), + timestamp: Some(1_700_000_006), + }; + let through = BlockRef { + number: 7, + hash: B256::repeat_byte(7), + parent_hash: Some(baseline.hash), + timestamp: Some(1_700_000_007), + }; + let rpc_block = || -> Block { + Block::empty(Header { + hash: through.hash, + inner: alloy_consensus::Header { + number: through.number, + parent_hash: through.parent_hash.expect("parent"), + timestamp: through.timestamp.expect("timestamp"), + ..Default::default() + }, + total_difficulty: None, + size: None, + }) + }; + let mut historical = rpc_log(false); + historical.block_hash = Some(through.hash); + historical.block_timestamp = through.timestamp; + asserter.push_success(&Some(rpc_block())); + asserter.push_success(&vec![historical]); + asserter.push_success(&Some(rpc_block())); + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Polling, + SubscriberConfig { + max_pending_records: 1, + ..SubscriberConfig::default() + }, ); + subscriber.chain_id = Some(1); + let interest = ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(Address::repeat_byte(0x42)), + local_matcher: None, + route_key: None, + }); + let epoch = subscriber + .stage_interest_owner( + HandlerId::new("capacity-owner"), + std::slice::from_ref(&interest), + SubscriberOwnerStart::PostBlock(baseline), + ) + .expect("stage owner"); + // Isolate the commit-side capacity edge: the live queue acquired one + // canonical record while the historical request was in flight. + subscriber.sources_dirty = false; + subscriber.state = AlloySubscriberState::Empty; + subscriber.push_pending_record(SubscriberInputRecord { + record: log_input_record(rpc_log(false), InputSource::Poll), + scope: SubscriberInputScope::Canonical { owners: Vec::new() }, + }); + let error = subscriber + .reconcile_interest_owner(&epoch, through) + .await + .expect_err("historical delivery cannot displace the queued live record"); assert!(matches!( - stream.next().await, - Some(SubscriberEvent::PendingHash(hash)) if hash == B256::repeat_byte(0xaa) + error, + SubscriberOwnerError::Subscriber(SubscriberError::ResourceExhausted(_)) )); + assert!(subscriber.interest_owner_progress(&epoch).is_none()); + assert_eq!(subscriber.pending_records.len(), 1); assert!(matches!( - stream.next().await, - Some(SubscriberEvent::StreamTerminated(source)) if source.is_pubsub() + subscriber.pending_records[0].scope, + SubscriberInputScope::Canonical { .. } )); - assert!(stream.next().await.is_none()); } #[test] - fn reconnect_delay_doubles_until_capped() { - assert_eq!( - next_reconnect_delay(Duration::from_millis(250), Duration::from_secs(1)), - Duration::from_millis(500) + fn lazy_backfill_queue_capacity_failure_is_atomic() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + max_pending_backfills: 1, + ..SubscriberConfig::default() + }, ); - assert_eq!( - next_reconnect_delay(Duration::from_millis(750), Duration::from_secs(1)), - Duration::from_secs(1) + let interest = |address| { + ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address), + local_matcher: None, + route_key: None, + }) + }; + subscriber + .add_interest_owner_with_backfill( + HandlerId::new("owner-a"), + &[interest(Address::repeat_byte(0x41))], + SubscriberBackfill::from_block(10), + ) + .expect("first queued backfill"); + + let error = subscriber + .add_interest_owner_with_backfill( + HandlerId::new("owner-b"), + &[interest(Address::repeat_byte(0x42))], + SubscriberBackfill::from_block(10), + ) + .expect_err("second backfill must exceed capacity"); + + assert!(matches!(error, SubscriberError::ResourceExhausted(_))); + assert!( + subscriber + .owner_interests(&HandlerId::new("owner-b")) + .is_none() ); - assert_eq!( - next_reconnect_delay(Duration::ZERO, Duration::from_secs(1)), - Duration::ZERO + assert_eq!(subscriber.pending_backfills.len(), 1); + } + + #[test] + fn exact_owner_replacement_is_atomic_and_removes_crash_stale_owners() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + max_pending_backfills: 1, + ..SubscriberConfig::default() + }, + ); + let interest = |address| { + ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address), + local_matcher: None, + route_key: None, + }) + }; + subscriber + .add_interest_owner( + HandlerId::new("crash-stale"), + &[interest(Address::repeat_byte(0xee))], + ) + .expect("seed stale owner"); + subscriber.base_interests = vec![interest(Address::repeat_byte(0xdd))]; + subscriber.rebuild_registered_interests(); + subscriber.push_pending_record(SubscriberInputRecord { + record: log_input_record(rpc_log(false), InputSource::Poll), + scope: SubscriberInputScope::Canonical { owners: Vec::new() }, + }); + let baseline = BlockRef { + number: 100, + hash: B256::repeat_byte(100), + parent_hash: Some(B256::repeat_byte(99)), + timestamp: Some(1_700_000_100), + }; + let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1"); + + let error = subscriber + .replace_interest_owners_with_global_backfill( + vec![ + ( + HandlerId::new("pool-a"), + vec![interest(Address::repeat_byte(0xa1))], + ), + ( + HandlerId::new("pool-b"), + vec![ReactiveInterest::Logs(LogInterest { + // A distinct block option prevents provider-filter + // fan-in, exercising the two-unit capacity edge. + provider_filter: Filter::new() + .address(Address::repeat_byte(0xb2)) + .from_block(7), + local_matcher: None, + route_key: None, + })], + ), + ], + backfill, + ) + .expect_err("two backfills exceed atomic capacity"); + assert!(matches!(error, SubscriberError::ResourceExhausted(_))); + assert!( + subscriber + .owner_interests(&HandlerId::new("crash-stale")) + .is_some(), + "failed replacement must preserve the prior topology" + ); + assert!( + subscriber + .owner_interests(&HandlerId::new("pool-a")) + .is_none() + ); + assert_eq!(subscriber.base_interests.len(), 1); + assert_eq!(subscriber.pending_records.len(), 1); + + subscriber + .replace_interest_owners_with_global_backfill( + vec![( + HandlerId::new("pool-a"), + vec![interest(Address::repeat_byte(0xa1))], + )], + backfill, + ) + .expect("replacement within capacity"); + assert!( + subscriber + .owner_interests(&HandlerId::new("crash-stale")) + .is_none(), + "successful exact replacement removes stale owners" ); + assert!( + subscriber.base_interests.is_empty(), + "successful exact replacement removes stale unowned interests" + ); + assert!( + subscriber.drain_next_scoped_batch().is_none(), + "stale canonical delivery must not escape before C + 1 recovery" + ); + assert!( + subscriber + .owner_interests(&HandlerId::new("pool-a")) + .is_some() + ); + assert_eq!(subscriber.pending_backfills.len(), 1); + assert_eq!(subscriber.pending_backfills[0].backfill, backfill); + assert!( + subscriber.pending_backfills[0].owner.is_none(), + "startup history must be global canonical catch-up, not owner-only" + ); + } + + #[test] + fn exclusive_canonical_backfill_rejects_block_number_overflow() { + let baseline = BlockRef { + number: u64::MAX, + hash: B256::repeat_byte(0xff), + parent_hash: None, + timestamp: None, + }; + assert!(matches!( + SubscriberBackfill::after_canonical_block(baseline), + Err(SubscriberError::InvalidConfig(_)) + )); } - #[test] - fn canonical_logs_are_deduped_but_removed_logs_are_not() { - let included = log_input_record::(rpc_log(false), InputSource::Subscription); - let removed = log_input_record::(rpc_log(true), InputSource::Subscription); + #[tokio::test(flavor = "multi_thread")] + async fn exclusive_canonical_backfill_validates_the_retained_baseline_hash() { + let asserter = Asserter::new(); + asserter.push_success(&101u64); + asserter.push_success(&Some(rpc_block(101, B256::repeat_byte(101)))); + asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0xee)))); + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig::default(), + ); + let baseline = BlockRef { + number: 100, + hash: B256::repeat_byte(0xaa), + parent_hash: None, + timestamp: None, + }; + let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1"); + subscriber + .add_interest_owner_with_backfill( + HandlerId::new("pool"), + &[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(Address::repeat_byte(0xa1)), + local_matcher: None, + route_key: None, + })], + backfill, + ) + .expect("queue post-baseline backfill"); - assert!(should_dedupe_record(&included)); - assert!(!should_dedupe_record(&removed)); + let error = subscriber + .drain_pending_backfills() + .await + .expect_err("provider branch differs at retained baseline"); + assert!(matches!(error, SubscriberError::InvalidBackfill(_))); + assert_eq!(subscriber.pending_backfills.len(), 1); + assert_eq!(subscriber.pending_backfills[0].backfill.start_block(), 101); + assert!(subscriber.pending_records.is_empty()); } - #[test] - fn active_owner_replacement_commits_atomically_to_one_new_epoch() { - let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "reactive-ws")] + async fn coordinated_multifilter_windows_are_globally_sorted_for_owner_and_canonical_delivery() + { + let asserter = Asserter::new(); + let retained = BlockRef { + number: 10, + hash: B256::repeat_byte(10), + parent_hash: Some(B256::repeat_byte(9)), + timestamp: Some(1_700_000_010), + }; + let activation = BlockRef { + number: 12, + hash: B256::repeat_byte(12), + parent_hash: Some(B256::repeat_byte(11)), + timestamp: Some(1_700_000_012), + }; + + // 257 distinct logical block options cross the 256-filter request + // chunk boundary. Each window therefore makes two concurrent log + // requests whose responses deliberately arrive in reverse order. + asserter.push_success(&Some(rpc_block(retained.number, retained.hash))); + asserter.push_success(&vec![rpc_log_at(10, 2, 2)]); + asserter.push_success(&vec![rpc_log_at(10, 1, 1)]); + asserter.push_success(&Some(rpc_block(retained.number, retained.hash))); + asserter.push_success(&activation.number); + asserter.push_success(&Some(rpc_block(activation.number, activation.hash))); + asserter.push_success(&Some(rpc_block(retained.number, retained.hash))); + asserter.push_success(&vec![rpc_log_at(12, 2, 2)]); + asserter.push_success(&vec![rpc_log_at(11, 1, 1)]); + asserter.push_success(&Some(rpc_block(activation.number, activation.hash))); + let provider = ProviderBuilder::new().connect_mocked_client(asserter); let mut subscriber = AlloySubscriber::<_, Ethereum>::new( provider, - SubscriberMode::Polling, + SubscriberMode::Auto, SubscriberConfig::default(), ); - let owner = HandlerId::new("replace-owner"); - let original = ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(Address::repeat_byte(0x41)), - local_matcher: None, - route_key: None, - }); - let replacement_interest = ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(Address::repeat_byte(0x42)), - local_matcher: None, - route_key: None, - }); - let active = subscriber - .stage_interest_owner(owner.clone(), &[original], SubscriberOwnerStart::Live) - .unwrap(); - assert!(subscriber.activate_interest_owner(&active)); - let replacement = subscriber - .stage_interest_owner_replacement( - owner, - &[replacement_interest], - SubscriberOwnerStart::Live, + let interests = (0..257) + .map(|start| { + ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new() + .address(Address::repeat_byte(0x42)) + .event_signature(B256::repeat_byte(0x01)) + .from_block(start), + local_matcher: None, + route_key: None, + }) + }) + .collect::>(); + subscriber + .add_interest_owner_with_canonical_catchup( + HandlerId::new("many-filters"), + &interests, + retained, ) - .unwrap(); + .expect("queue coordinated windows"); + assert_eq!(subscriber.pending_backfills.len(), 2); + assert_eq!(subscriber.pending_backfills[0].filters.len(), 257); + assert_eq!(subscriber.pending_backfills[1].filters.len(), 257); - assert!(subscriber.commit_interest_owner_replacement(&active, &replacement)); - assert_eq!(subscriber.interest_owner_state(&active), None); + subscriber + .drain_pending_backfills() + .await + .expect("owner filter group"); + let owner = subscriber + .drain_next_scoped_batch() + .expect("owner ordered batch"); + assert_eq!(owner.records.len(), 2); + assert_eq!(owner.records[0].record.context.transaction_index, Some(1)); + assert_eq!(owner.records[1].record.context.transaction_index, Some(2)); + assert!( + owner.records.iter().all(|record| matches!( + record.scope, + SubscriberInputScope::OwnerOnlyHandlers { .. } + )) + ); + + subscriber + .drain_pending_backfills() + .await + .expect("global filter group"); + let global = subscriber + .drain_next_scoped_batch() + .expect("global ordered batch"); + assert_eq!(global.records.len(), 2); assert_eq!( - subscriber.interest_owner_state(&replacement), - Some(SubscriberOwnerState::Active) + global.records[0].record.context.block.map(|b| b.number), + Some(11) ); - assert_eq!(subscriber.registered_interests().len(), 1); + assert_eq!( + global.records[1].record.context.block.map(|b| b.number), + Some(12) + ); + assert!( + global + .records + .iter() + .all(|record| record.scope.is_canonical()) + ); + assert!(matches!( + global.chain_controls.as_slice(), + [ChainControl::Barrier { + block: Some(block), + .. + }] if block == &activation + )); } #[tokio::test(flavor = "multi_thread")] @@ -7189,6 +16799,7 @@ mod subscriber_helper_tests { SubscriberMode::PubSub, SubscriberConfig::default(), ); + subscriber.chain_id = Some(1); let interest = ReactiveInterest::Logs(LogInterest { provider_filter: Filter::new().address(Address::repeat_byte(0x42)), local_matcher: None, @@ -7238,6 +16849,7 @@ mod subscriber_helper_tests { SubscriberMode::PubSub, SubscriberConfig::default(), ); + subscriber.chain_id = Some(1); let epoch = subscriber .stage_interest_owner( HandlerId::new("owner"), @@ -7362,6 +16974,7 @@ mod subscriber_helper_tests { SubscriberMode::Polling, SubscriberConfig::default(), ); + subscriber.chain_id = Some(1); subscriber.sources_dirty = false; let mut first_poll = true; let fetch = poll_fn(move |cx| { @@ -7421,6 +17034,7 @@ mod subscriber_helper_tests { SubscriberMode::PubSub, SubscriberConfig::default(), ); + subscriber.chain_id = Some(1); let interest = ReactiveInterest::Logs(LogInterest { provider_filter: Filter::new().address(Address::repeat_byte(0xac)), local_matcher: None, @@ -7444,7 +17058,7 @@ mod subscriber_helper_tests { subscriber.sources_dirty = false; subscriber - .reconcile_interest_owner(&epoch, through.clone()) + .reconcile_interest_owner(&epoch, through) .await .unwrap(); assert_eq!(subscriber.log_anchor(&filter), Some(through.number)); @@ -7549,6 +17163,7 @@ mod subscriber_helper_tests { ..SubscriberConfig::default() }, ); + subscriber.chain_id = Some(1); let interest = ReactiveInterest::Logs(LogInterest { provider_filter: Filter::new().address(Address::repeat_byte(0x42)), local_matcher: None, @@ -7575,7 +17190,7 @@ mod subscriber_helper_tests { .unwrap(); entry.progress = Some(SubscriberOwnerProgress { owner: epoch.clone(), - through: entry.baseline.clone().unwrap(), + through: entry.baseline.unwrap(), }); entry.progress_stream_revision = Some(1); @@ -7617,15 +17232,16 @@ mod subscriber_helper_tests { ); } - #[test] + #[tokio::test] #[cfg(feature = "reactive-ws")] - fn pubsub_sources_assign_stable_log_ids_before_shared_streams() { + async fn pubsub_sources_assign_stable_log_ids_before_shared_streams() { let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); let mut subscriber = AlloySubscriber::<_, Ethereum>::new( provider, SubscriberMode::PubSub, SubscriberConfig::default(), ); + subscriber.chain_id = Some(1); subscriber .register_interests(&[ ReactiveInterest::Logs(LogInterest { @@ -7640,6 +17256,7 @@ mod subscriber_helper_tests { }), ReactiveInterest::PendingTransactions(PendingTxInterest::default()), ]) + .await .expect("register base interests"); // The two default-block-option log filters merge into one address @@ -7679,6 +17296,7 @@ mod subscriber_helper_tests { ..SubscriberConfig::default() }, ); + subscriber.chain_id = Some(1); subscriber.interests = vec![ReactiveInterest::PendingTransactions( PendingTxInterest::default(), )]; @@ -7938,7 +17556,9 @@ mod subscriber_helper_tests { #[cfg(feature = "reactive-ws")] async fn owner_backfill_seeds_reconnect_anchor_before_live_log() { let asserter = Asserter::new(); + asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02)))); asserter.push_success(&vec![rpc_log(false)]); + asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02)))); let provider = ProviderBuilder::new().connect_mocked_client(asserter); let mut subscriber = AlloySubscriber::<_, Ethereum>::new( provider, @@ -8006,10 +17626,12 @@ mod subscriber_helper_tests { SubscriberMode::PubSub, SubscriberConfig::default(), ); + subscriber.chain_id = Some(1); subscriber .register_interests(&[ReactiveInterest::PendingTransactions( PendingTxInterest::default(), )]) + .await .expect("register base pending interest"); subscriber .add_interest_owner( @@ -8062,6 +17684,129 @@ mod subscriber_helper_tests { )); } + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "reactive-polling")] + async fn ensure_streams_retains_each_successful_connection_across_later_failure() { + let asserter = Asserter::new(); + asserter.push_success(&U256::from(1)); + asserter.push_failure_msg("second filter connection failed"); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Polling, + SubscriberConfig { + max_log_addresses_per_subscription: 1, + ..SubscriberConfig::default() + }, + ); + subscriber.chain_id = Some(1); + subscriber + .register_interests(&[log_interest_for(0x41), log_interest_for(0x42)]) + .await + .expect("register two independently connected filters"); + + let error = subscriber + .ensure_streams() + .await + .expect_err("second provider connection is forced to fail"); + assert!(matches!(error, SubscriberError::Provider(_))); + assert!(subscriber.sources_dirty); + let retained_streams = match &subscriber.state { + AlloySubscriberState::Active(streams) => Some(streams.len()), + AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None, + }; + assert_eq!( + retained_streams, + Some(1), + "first connection must survive later error {error:?}; revision {}", + subscriber.stream_revision + ); + + asserter.push_success(&U256::from(2)); + subscriber + .ensure_streams() + .await + .expect("retry connects only the missing source"); + assert!(!subscriber.sources_dirty); + assert!(matches!( + &subscriber.state, + AlloySubscriberState::Active(streams) if streams.len() == 2 + )); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "reactive-ws")] + async fn cancelled_post_install_backfill_is_retried_without_reconnecting() { + let asserter = Asserter::new(); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ); + subscriber.chain_id = Some(1); + subscriber + .register_interests(&[log_interest_for(0x43)]) + .await + .expect("register log source"); + let source = subscriber + .stream_sources() + .expect("one desired source") + .pop() + .expect("log source"); + let SubscriberStreamSource::PubSubLog { id, .. } = source else { + panic!("expected pubsub log source") + }; + subscriber.last_seen_log_blocks.insert(id, 6); + + { + let source = SubscriberStreamSource::PubSubLog { + id, + filter: subscriber + .log_stream_filters() + .pop() + .expect("provider filter"), + }; + let interrupted = async { + subscriber.install_source_stream( + source.clone(), + stream::pending::>().boxed(), + ); + subscriber.queue_source_backfill(source); + subscriber.sources_dirty = true; + futures::future::pending::<()>().await; + }; + futures::pin_mut!(interrupted); + poll_fn(|cx| { + assert!(interrupted.as_mut().poll(cx).is_pending()); + std::task::Poll::Ready(()) + }) + .await; + } + + assert_eq!(subscriber.pending_source_backfills.len(), 1); + assert!(matches!( + &subscriber.state, + AlloySubscriberState::Active(streams) if streams.len() == 1 + )); + + asserter.push_success(&7u64); + asserter.push_success(&Vec::::new()); + subscriber + .ensure_streams() + .await + .expect("retry completes only the pending historical window"); + + assert!(subscriber.pending_source_backfills.is_empty()); + assert!(!subscriber.sources_dirty); + assert!(matches!( + &subscriber.state, + AlloySubscriberState::Active(streams) if streams.len() == 1 + )); + assert!(asserter.read_q().is_empty()); + } + // A log interest matching `rpc_log` (address 0x42, topic0 0x01). #[cfg(feature = "reactive-ws")] fn log_interest_matching_rpc_log() -> ReactiveInterest { @@ -8074,7 +17819,7 @@ mod subscriber_helper_tests { }) } - #[cfg(feature = "reactive-ws")] + #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))] fn log_interest_for(address: u8) -> ReactiveInterest { ReactiveInterest::Logs(LogInterest { provider_filter: Filter::new().address(Address::repeat_byte(address)), @@ -8090,7 +17835,9 @@ mod subscriber_helper_tests { async fn drain_backfill_retains_queue_entry_on_provider_error() { let asserter = Asserter::new(); asserter.push_failure_msg("rate limited"); + asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02)))); asserter.push_success(&vec![rpc_log(false)]); + asserter.push_success(&Some(rpc_block(7, B256::repeat_byte(0x02)))); let provider = ProviderBuilder::new().connect_mocked_client(asserter); let mut subscriber = AlloySubscriber::new( provider, @@ -8129,7 +17876,9 @@ mod subscriber_helper_tests { #[cfg(feature = "reactive-ws")] async fn drain_backfill_seeds_anchor_on_empty_window() { let asserter = Asserter::new(); + asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42)))); asserter.push_success(&Vec::::new()); + asserter.push_success(&Some(rpc_block(42, B256::repeat_byte(42)))); let provider = ProviderBuilder::new().connect_mocked_client(asserter); let mut subscriber = AlloySubscriber::new( provider, @@ -8167,7 +17916,9 @@ mod subscriber_helper_tests { async fn drain_backfill_open_ended_resolves_head_and_seeds_anchor() { let asserter = Asserter::new(); asserter.push_success(&100u64); // get_block_number + asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100)))); asserter.push_success(&Vec::::new()); // get_logs + asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(100)))); let provider = ProviderBuilder::new().connect_mocked_client(asserter); let mut subscriber = AlloySubscriber::new( provider, @@ -8308,7 +18059,7 @@ mod subscriber_helper_tests { "the changed merged filter should queue exactly one continuity backfill" ); let queued = &subscriber.pending_backfills[0]; - assert_eq!(queued.owner, HandlerId::new("amm")); + assert_eq!(queued.owner, Some(HandlerId::new("amm"))); assert_eq!(queued.backfill.start_block(), 50); assert_eq!( queued.backfill.end_block(), @@ -8467,6 +18218,13 @@ fn resolve_auto_subscriber_transport() -> Result Result<(), SubscriberError> { + if config.preconfirmations != PreconfirmationMode::Disabled + && config.flashblock_poll_interval.is_zero() + { + return Err(SubscriberError::InvalidConfig( + "SubscriberConfig::flashblock_poll_interval must be greater than zero", + )); + } if config.max_batch_size == 0 { return Err(SubscriberError::InvalidConfig( "SubscriberConfig::max_batch_size must be greater than zero", @@ -8477,6 +18235,26 @@ fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), Subscribe "SubscriberConfig::max_log_addresses_per_subscription must be greater than zero", )); } + if config.max_pending_records == 0 { + return Err(SubscriberError::InvalidConfig( + "SubscriberConfig::max_pending_records must be greater than zero", + )); + } + if config.max_pending_backfills == 0 { + return Err(SubscriberError::InvalidConfig( + "SubscriberConfig::max_pending_backfills must be greater than zero", + )); + } + if config.max_backfill_log_bytes == 0 { + return Err(SubscriberError::InvalidConfig( + "SubscriberConfig::max_backfill_log_bytes must be greater than zero", + )); + } + if config.max_reconcile_requests_in_flight == 0 { + return Err(SubscriberError::InvalidConfig( + "SubscriberConfig::max_reconcile_requests_in_flight must be greater than zero", + )); + } if config.reconnect.enabled { if config.reconnect.retry_delay > config.reconnect.max_delay { return Err(SubscriberError::InvalidConfig( @@ -8614,6 +18392,68 @@ fn validate_owner_backfill_logs( Ok(()) } +fn validate_backfill_resource_limits( + logs: &[Log], + max_logs: usize, + max_log_bytes: usize, +) -> Result { + if logs.len() > max_logs { + return Err(SubscriberError::ResourceExhausted(format!( + "historical response returned {} logs, above the configured limit of {max_logs}", + logs.len() + ))); + } + let bytes = logs.iter().fold(0usize, |total, log| { + // Include fixed address/block/transaction/index fields in addition to + // the variable topic and data payload. This is deliberately a stable + // conservative accounting unit rather than Rust heap-layout size. + let fixed = 20usize + (32 * 3) + (8 * 4) + 1; + total + .saturating_add(fixed) + .saturating_add(log.topics().len().saturating_mul(32)) + .saturating_add(log.inner.data.data.len()) + }); + if bytes > max_log_bytes { + return Err(SubscriberError::ResourceExhausted(format!( + "historical response retained approximately {bytes} log bytes, above the configured limit of {max_log_bytes}" + ))); + } + Ok(bytes) +} + +async fn fetch_provider_block_ref( + provider: &P, + number: u64, +) -> Result +where + P: Provider + Send + Sync, + N: Network, +{ + let block = provider + .get_block_by_number(BlockNumberOrTag::Number(number)) + .await + .map_err(provider_error)? + .ok_or_else(|| { + SubscriberError::InvalidBackfill(format!( + "canonical target block {number} is unavailable" + )) + })?; + let header = block.header(); + Ok(BlockRef { + number: header.number(), + hash: header.hash(), + parent_hash: Some(header.parent_hash()), + timestamp: Some(header.timestamp()), + }) +} + +fn block_ref_satisfies_expected(actual: &BlockRef, expected: &BlockRef) -> bool { + actual.number == expected.number + && actual.hash == expected.hash + && optional_metadata_compatible(actual.parent_hash.as_ref(), expected.parent_hash.as_ref()) + && optional_metadata_compatible(actual.timestamp.as_ref(), expected.timestamp.as_ref()) +} + fn validate_owner_backfill_log_set(logs: &[Log]) -> Result<(), SubscriberOwnerError> { let mut positions = HashMap::new(); let mut block_hashes = HashMap::new(); @@ -8709,28 +18549,73 @@ fn merged_owner_reconcile_filters( chunks } +fn merged_lazy_backfill_filters( + filters: &[Filter], + from_block: u64, + through: u64, +) -> Vec { + let mut requests = Vec::new(); + for filters in filters.chunks(OWNER_RECONCILE_FILTERS_PER_CHUNK) { + let mut merged = Vec::new(); + for filter in filters { + merge_log_subscription_filter( + &mut merged, + &filter.clone().from_block(from_block).to_block(through), + ); + } + requests.extend( + merged + .into_iter() + .map(|filter| SubscriberOwnerReconcileFilter { filter, from_block }), + ); + } + requests +} + +fn lazy_backfill_error(error: SubscriberOwnerError) -> SubscriberError { + match error { + SubscriberOwnerError::Subscriber(error) => error, + error => SubscriberError::InvalidBackfill(error.to_string()), + } +} + +fn global_backfill_barrier(backfill: SubscriberBackfill, certified: BlockRef) -> ChainControl { + let mut id = b"alloy-global-backfill-v1".to_vec(); + id.extend_from_slice(&backfill.start_block().to_be_bytes()); + id.extend_from_slice(&certified.number.to_be_bytes()); + id.extend_from_slice(certified.hash.as_slice()); + ChainControl::Barrier { + id, + block: Some(certified), + } +} + async fn fetch_owner_catchup( provider: P, filters: Vec, retained: Vec, through: BlockRef, + options: SubscriberOwnerCatchupOptions, ) -> Result where P: Provider + Send + Sync, N: Network, { - let _ = verify_provider_reconcile_target::(&provider, &through).await?; + if !options.target_preverified { + let _ = verify_provider_reconcile_target::(&provider, &through).await?; + } let mut certified_positions = HashSet::new(); for position in retained { let target_certifies_position = position == through || (position.number.checked_add(1) == Some(through.number) && through.parent_hash == Some(position.hash)); - if !target_certifies_position && certified_positions.insert(position.clone()) { + if !target_certifies_position && certified_positions.insert(position) { let _ = verify_provider_reconcile_target::(&provider, &position).await?; } } let mut logs = Vec::new(); - let requests = filters.into_iter().map(|filter| { + let mut total_log_bytes = 0usize; + let requests = stream::iter(filters.into_iter().map(|filter| { let provider = &provider; async move { let logs = provider @@ -8739,9 +18624,29 @@ where .map_err(provider_error)?; Ok::<_, SubscriberOwnerError>((filter.from_block, logs)) } - }); - for (from_block, fetched) in try_join_all(requests).await? { + })) + .buffer_unordered(options.max_requests_in_flight); + futures::pin_mut!(requests); + while let Some(result) = requests.next().await { + let (from_block, fetched) = result?; + let fetched_bytes = + validate_backfill_resource_limits(&fetched, options.max_logs, options.max_log_bytes)?; validate_owner_backfill_logs(&fetched, from_block, &through)?; + if logs.len().saturating_add(fetched.len()) > options.max_logs { + return Err(SubscriberError::ResourceExhausted(format!( + "bulk reconcile returned more than {} logs", + options.max_logs + )) + .into()); + } + total_log_bytes = total_log_bytes.saturating_add(fetched_bytes); + if total_log_bytes > options.max_log_bytes { + return Err(SubscriberError::ResourceExhausted(format!( + "bulk reconcile retained approximately {total_log_bytes} log bytes, above the configured limit of {}", + options.max_log_bytes + )) + .into()); + } logs.extend(fetched); } validate_owner_backfill_log_set(&logs)?; @@ -8798,6 +18703,26 @@ fn log_input_record(log: Log, source: InputSource) -> ReactiveInputR ) } +fn preconfirmed_log_input_record( + log: Log, + flashblock: FlashblockRef, +) -> ReactiveInputRecord { + let block = flashblock.block_ref(); + let provider = flashblock.provider.clone(); + ReactiveInputRecord::new( + ReactiveInput::Log(log.clone()), + ReactiveContext { + chain_id: None, + source: InputSource::Flashblocks, + chain_status: ChainStatus::Preconfirmed { flashblock }, + block: Some(block), + transaction_index: log.transaction_index, + log_index: log.log_index, + }, + ) + .with_provider(provider) +} + fn log_reactive_context(log: &Log) -> ReactiveContext { let block = match (log.block_hash, log.block_number) { (Some(hash), Some(number)) => Some(BlockRef { @@ -8811,10 +18736,10 @@ fn log_reactive_context(log: &Log) -> ReactiveContext { let chain_status = match (&block, log.removed) { (Some(block), true) => ChainStatus::Reorged { - dropped_from: block.clone(), + dropped_from: *block, }, (Some(block), false) => ChainStatus::Included { - block: block.clone(), + block: *block, confirmations: 0, }, (None, _) => ChainStatus::Pending, @@ -8846,7 +18771,7 @@ where chain_id: None, source: InputSource::Subscription, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -8873,12 +18798,26 @@ fn pending_hash_input_record( ) } +#[cfg(feature = "reactive-ws")] +fn base_pending_log_filter(filter: &Filter) -> Result { + let encoded = serde_json::to_value(filter) + .map_err(|error| SubscriberError::Provider(error.to_string()))?; + let serde_json::Value::Object(mut fields) = encoded else { + return Err(SubscriberError::Provider( + "Alloy log filter did not serialize as an object".into(), + )); + }; + fields.retain(|key, _| key == "address" || key == "topics"); + Ok(serde_json::Value::Object(fields)) +} + fn provider_error(error: impl fmt::Display) -> SubscriberError { SubscriberError::Provider(error.to_string()) } /// Subscriber error. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum SubscriberError { /// Invalid subscriber configuration. #[error("{0}")] @@ -8889,4 +18828,11 @@ pub enum SubscriberError { /// Provider or transport error. #[error("provider error: {0}")] Provider(String), + /// A provider returned malformed, out-of-range, or non-canonical lazy + /// backfill data. + #[error("invalid canonical backfill: {0}")] + InvalidBackfill(String), + /// A configured subscriber memory/concurrency boundary was exceeded. + #[error("subscriber resource limit exceeded: {0}")] + ResourceExhausted(String), } diff --git a/tests/block_context.rs b/tests/block_context.rs index bf50b3e..2d2d914 100644 --- a/tests/block_context.rs +++ b/tests/block_context.rs @@ -1,4 +1,4 @@ -//! Manager-authored red-green acceptance tests for WS-2 / Phase-8 step 2: +//! Red-green acceptance tests for WS-2 / Phase-8 step 2: //! strict block-context requirements and engine-driven `advance_block` env //! refresh. //! @@ -17,9 +17,10 @@ mod common; -use alloy_consensus::Header; +use alloy_consensus::{BlockHeader as _, Header}; use alloy_eips::BlockId; -use alloy_primitives::{Address, B256}; +use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog}; +use alloy_rpc_types_eth::Log; use anyhow::Result; use common::setup_cache; @@ -106,6 +107,28 @@ async fn advance_block_refreshes_all_block_env_fields() -> Result<()> { Ok(()) } +/// A low-level repin has no header from which to refresh the EVM environment. +/// It must fail closed instead of pairing the new state pin with old header +/// fields, including values that could have been manually overridden. +#[tokio::test] +async fn set_block_clears_every_stale_header_field_on_repin() -> Result<()> { + let mut cache = setup_cache().await?; + cache + .advance_block(&header(100, Some(42))) + .expect("install complete old header"); + + cache.set_block(BlockId::number(101)); + + assert_eq!(cache.block(), BlockId::number(101)); + assert_eq!(cache.block_number(), Some(101)); + assert_eq!(cache.basefee(), None); + assert_eq!(cache.coinbase(), None); + assert_eq!(cache.prevrandao(), None); + assert_eq!(cache.block_gas_limit(), None); + assert_eq!(cache.timestamp(), None); + Ok(()) +} + /// WS-2 / Phase-8 s2: under strict requirements, `advance_block` fails loudly on /// a header missing a required field instead of silently defaulting it. #[tokio::test] @@ -130,19 +153,19 @@ async fn advance_block_strict_rejects_incomplete_header() -> Result<()> { Ok(()) } -// --- Additional coverage (implementation agent, Wave 4) -------------------- +// --- Additional Wave 4 coverage -------------------------------------------- use std::sync::Arc; -use alloy_network::Ethereum; +use alloy_network::{Ethereum, primitives::HeaderResponse as _}; use alloy_provider::RootProvider; use alloy_provider::network::AnyNetwork; use alloy_rpc_client::RpcClient; use alloy_transport::mock::Asserter; use evm_fork_cache::EvmCacheBuilder; use evm_fork_cache::reactive::{ - ChainStatus, InputSource, ReactiveConfig, ReactiveContext, ReactiveInput, ReactiveInputBatch, - ReactiveInputRecord, ReactiveReport, ReactiveRuntime, + BlockRef, ChainControl, ChainStatus, InputSource, ReactiveConfig, ReactiveContext, + ReactiveInput, ReactiveInputBatch, ReactiveInputRecord, ReactiveReport, ReactiveRuntime, }; /// Build a mocked provider (no network access) modelled on `common::setup_cache`. @@ -218,18 +241,18 @@ fn rpc_header(number: u64, basefee: Option) -> alloy_rpc_types_eth::Header } /// A canonical (`Included`) context for a block header at `number`. -fn included_header_context(number: u64) -> ReactiveContext { +fn included_header_context(header: &alloy_rpc_types_eth::Header) -> ReactiveContext { let block = evm_fork_cache::reactive::BlockRef { - number, - hash: B256::repeat_byte(0x11), - parent_hash: Some(B256::repeat_byte(0x10)), - timestamp: Some(1_700_000_000 + number), + number: header.number(), + hash: header.hash(), + parent_hash: Some(header.parent_hash()), + timestamp: Some(header.timestamp()), }; ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -245,11 +268,10 @@ async fn reactive_ingest_of_canonical_header_refreshes_block_env() -> Result<()> let mut cache = setup_cache().await?; let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); - let input = ReactiveInput::BlockHeader(rpc_header(7_777, Some(123))); - let batch = ReactiveInputBatch::new(vec![ReactiveInputRecord::new( - input, - included_header_context(7_777), - )]); + let header = rpc_header(7_777, Some(123)); + let context = included_header_context(&header); + let input = ReactiveInput::BlockHeader(header); + let batch = ReactiveInputBatch::new(vec![ReactiveInputRecord::new(input, context)]); let report = runtime.ingest_batch(&mut cache, batch)?; @@ -268,6 +290,90 @@ async fn reactive_ingest_of_canonical_header_refreshes_block_env() -> Result<()> Ok(()) } +#[tokio::test] +async fn post_record_compact_barrier_preserves_full_header_environment() -> Result<()> { + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + let header = rpc_header(7_778, Some(124)); + let context = included_header_context(&header); + let exact_hash = header.hash(); + let compact = BlockRef { + number: header.number(), + hash: exact_hash, + parent_hash: None, + timestamp: None, + }; + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::BlockHeader(header), + context, + )]) + .with_chain_controls([ChainControl::Barrier { + id: b"header-complete".to_vec(), + block: Some(compact), + }]), + )?; + + assert_eq!(cache.block(), BlockId::from((exact_hash, Some(true)))); + assert_eq!(cache.block_number(), Some(7_778)); + assert_eq!(cache.basefee(), Some(124)); + assert_eq!(cache.coinbase(), Some(Address::repeat_byte(0xcb))); + assert_eq!(cache.prevrandao(), Some(B256::repeat_byte(0xab))); + assert_eq!(cache.block_gas_limit(), Some(30_000_000)); + assert_eq!(cache.timestamp(), Some(1_700_000_000 + 7_778)); + Ok(()) +} + +#[tokio::test] +async fn zero_depth_runtime_preserves_full_header_env_for_same_block_compact_records() -> Result<()> +{ + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig { + journal_depth: 0, + ..ReactiveConfig::default() + }); + let header = rpc_header(7_779, Some(125)); + let context = included_header_context(&header); + let block = context.block.expect("canonical block"); + let log = Log { + inner: PrimitiveLog::new_unchecked( + Address::repeat_byte(0xdd), + vec![B256::repeat_byte(0xee)], + Bytes::new(), + ), + block_hash: Some(block.hash), + block_number: Some(block.number), + block_timestamp: block.timestamp, + transaction_hash: Some(B256::repeat_byte(0xef)), + transaction_index: Some(0), + log_index: Some(0), + removed: false, + }; + let log_context = ReactiveContext { + transaction_index: Some(0), + log_index: Some(0), + ..context.clone() + }; + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ + ReactiveInputRecord::new(ReactiveInput::BlockHeader(header), context), + ReactiveInputRecord::new(ReactiveInput::Log(log), log_context), + ]), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(block)); + assert_eq!(cache.basefee(), Some(125)); + assert_eq!(cache.coinbase(), Some(Address::repeat_byte(0xcb))); + assert_eq!(cache.prevrandao(), Some(B256::repeat_byte(0xab))); + assert_eq!(cache.block_gas_limit(), Some(30_000_000)); + assert_eq!(cache.timestamp(), Some(1_700_000_000 + 7_779)); + Ok(()) +} + /// Phase-8 s2: a pending (non-canonical) header must NOT drive `advance_block`. #[tokio::test] async fn reactive_ingest_of_pending_header_does_not_refresh_block_env() -> Result<()> { @@ -303,11 +409,10 @@ async fn reactive_strict_drive_surfaces_error_report_for_incomplete_header() -> let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); // No base fee -> strict validation fails during the drive. - let input = ReactiveInput::BlockHeader(rpc_header(4_242, None)); - let batch = ReactiveInputBatch::new(vec![ReactiveInputRecord::new( - input, - included_header_context(4_242), - )]); + let header = rpc_header(4_242, None); + let context = included_header_context(&header); + let input = ReactiveInput::BlockHeader(header); + let batch = ReactiveInputBatch::new(vec![ReactiveInputRecord::new(input, context)]); let report = runtime.ingest_batch(&mut cache, batch)?; @@ -325,3 +430,30 @@ async fn reactive_strict_drive_surfaces_error_report_for_incomplete_header() -> ); Ok(()) } + +#[tokio::test] +async fn canonical_block_records_are_sorted_before_advancing_runtime_and_cache_heads() -> Result<()> +{ + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + let older = rpc_header(50, Some(5)); + let newer = rpc_header(51, Some(6)); + let older_context = included_header_context(&older); + let newer_context = included_header_context(&newer); + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ + ReactiveInputRecord::new(ReactiveInput::BlockHeader(newer), newer_context), + ReactiveInputRecord::new(ReactiveInput::BlockHeader(older), older_context), + ]), + )?; + + assert_eq!( + runtime.last_canonical_block().map(|block| block.number), + Some(51) + ); + assert_eq!(cache.block_number(), Some(51)); + assert_eq!(cache.basefee(), Some(6)); + Ok(()) +} diff --git a/tests/bundle_simulation.rs b/tests/bundle_simulation.rs index 21e7200..7595b28 100644 --- a/tests/bundle_simulation.rs +++ b/tests/bundle_simulation.rs @@ -1,12 +1,9 @@ -//! Manager-authored red-green acceptance tests for Phase 6 Track A+B: +//! Acceptance tests for Phase 6 Track A+B: //! ordered multi-transaction bundle simulation over cumulative state, revert //! policy, and coinbase/payment accounting. //! -//! These describe the public contract before the implementation exists. The -//! implementation agent must make them pass WITHOUT weakening, skipping, or -//! rewriting them; if a test encodes a wrong assumption about EVM/mock behavior -//! (as opposed to the feature contract), surface it to the manager with a -//! justification rather than silently changing it. +//! These describe the public contract. Keep their assertions intact unless a +//! documented correction to an EVM or mock assumption is required. //! //! Fully offline (mocked provider, injected state). #![cfg(feature = "reactive")] @@ -301,7 +298,7 @@ async fn commit_flag_controls_overlay_persistence() -> Result<()> { Ok(()) } -/// WS-7 (manager-authored red-green): cost-accounting breakdown. After an +/// WS-7 red-green coverage: cost-accounting breakdown. After an /// `AllowReverts` bundle whose whitelisted tx reverts, the reverted tx's gas is /// excluded from `coinbase_payment` (the honest miner receipt) but is exposed via /// `reverted_tx_gas`, and `successful_tx_gas + reverted_tx_gas == gas_used`. This @@ -348,7 +345,7 @@ async fn allow_reverts_exposes_reverted_and_successful_gas() -> Result<()> { Ok(()) } -/// WS-7 (manager-authored red-green): a fully successful bundle reports zero +/// WS-7 red-green coverage: a fully successful bundle reports zero /// reverted gas and all gas in the successful bucket. #[tokio::test(flavor = "multi_thread")] async fn successful_bundle_reports_zero_reverted_gas() -> Result<()> { diff --git a/tests/call_tracer.rs b/tests/call_tracer.rs index e725433..b12bc7f 100644 --- a/tests/call_tracer.rs +++ b/tests/call_tracer.rs @@ -1,12 +1,9 @@ -//! Manager-authored red-green acceptance tests for Phase 6 Track C: the +//! Acceptance tests for Phase 6 Track C: the //! call-frame tracer (`CallTracer`) and the generalized public inspector seam //! (`EvmOverlay::call_raw_with_inspector` + `InspectorStack`). //! -//! These describe the public contract before the implementation exists. The -//! implementation agent must make them pass WITHOUT weakening, skipping, or -//! rewriting them; if a test encodes a wrong assumption about EVM/mock behavior -//! (as opposed to the feature contract), surface it to the manager with a -//! justification rather than silently changing it. +//! These describe the public contract. Keep their assertions intact unless a +//! documented correction to an EVM or mock assumption is required. //! //! Fully offline (mocked provider, injected state). #![cfg(feature = "reactive")] diff --git a/tests/code_seeding.rs b/tests/code_seeding.rs index 7a0193a..8c1e595 100644 --- a/tests/code_seeding.rs +++ b/tests/code_seeding.rs @@ -624,11 +624,13 @@ async fn verify_transport_failure_keeps_seeds_pending() -> Result<()> { ), "a failed read proves nothing: the seed must stay Pending" ); + assert_eq!(cache.snapshot_generation(), generation_before); assert!( cache.db_mut().cache.accounts.contains_key(&pool), "nothing may be purged on a transport failure" ); - assert_eq!(cache.snapshot_generation(), generation_before); + // `db_mut` advances the generation conservatively on mutable access; the + // equality above isolates the behavior of `verify_code_seeds` itself. assert_eq!(cache.pending_code_seeds(), vec![pool]); assert_eq!(calls.load(Ordering::SeqCst), 1); Ok(()) diff --git a/tests/cold_start.rs b/tests/cold_start.rs index 19e8111..1251ac3 100644 --- a/tests/cold_start.rs +++ b/tests/cold_start.rs @@ -11,10 +11,9 @@ //! //! Every test runs fully offline over a mocked provider; none reach the network //! (an unexpected RPC fetch errors against the empty mock queue, failing the -//! test). These are manager-authored red-green acceptance tests. The -//! implementation agent must make them pass without weakening, skipping, or -//! rewriting them. Where they disagree with the original feature request, the -//! implementation spec (`...cold-start-implementation-spec.md`) and these tests win. +//! test). These are red-green acceptance tests. Keep their assertions intact; +//! where they disagree with the original feature request, the implementation +//! spec (`...cold-start-implementation-spec.md`) and these tests win. #![cfg(feature = "reactive")] mod common; diff --git a/tests/durable_checkpoint.rs b/tests/durable_checkpoint.rs new file mode 100644 index 0000000..bb11f98 --- /dev/null +++ b/tests/durable_checkpoint.rs @@ -0,0 +1,3697 @@ +//! Crash-ordering and compatibility tests for durable reactive checkpoints. +#![cfg(feature = "reactive")] + +mod common; + +use std::{ + collections::{HashMap, HashSet, VecDeque}, + fs, + path::PathBuf, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, +}; + +use alloy_eips::BlockId; +use alloy_network::Ethereum; +use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256, keccak256}; +use alloy_rpc_types_eth::{Filter, Header, Log}; +use common::{install_mock_erc20, setup_cache}; +use evm_fork_cache::events::StateView; +use evm_fork_cache::freshness::FreshnessRegistry; +use evm_fork_cache::reactive::{ + BlockRef, CacheHealth, CacheMetricsSnapshot, ChainControl, ChainStatus, EventSubscriber, + HandlerError, HandlerId, HandlerOutcome, InputSource, LogInterest, ReactiveConfig, + ReactiveContext, ReactiveEffect, ReactiveEngine, ReactiveEngineError, ReactiveError, + ReactiveHandler, ReactiveHook, ReactiveInput, ReactiveInputBatch, ReactiveInputRecord, + ReactiveInterest, ReactiveReport, ResyncRequest, RootGateCadence, RouteKeySpec, + StateEffectQuality, SubscriberCapabilities, SubscriberCapability, SubscriberCheckpoint, + SubscriberDeliveryToken, SubscriberNextBatch, SubscriberOperation, SubscriberPayloadCommitment, + SubscriberResumePosition, TrackingPolicy, +}; +use evm_fork_cache::state_update::{SlotDelta, StateDiff, StateUpdate}; +use evm_fork_cache::{ + AccountProof, CheckpointedIngest, DurableCheckpointBlock, DurableCheckpointError, + DurableCheckpointIdentity, DurableCheckpointMetadata, DurableCheckpointStore, ReactiveRuntime, +}; + +fn temp_path(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "evm_fork_cache_durable_{tag}_{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create temp root"); + root.join("checkpoint.bin") +} + +fn identity() -> DurableCheckpointIdentity { + DurableCheckpointIdentity::new(1, "subscriber-a", "handlers-v1") +} + +fn metadata(block: u64, token: &[u8]) -> DurableCheckpointMetadata { + DurableCheckpointMetadata::new( + identity(), + DurableCheckpointBlock::new(block, B256::repeat_byte(block as u8)) + .with_parent_hash(B256::repeat_byte(block.saturating_sub(1) as u8)) + .with_timestamp(1_700_000_000 + block), + ) + .with_delivery_token(token) + .with_subscriber_checkpoint(b"provider-cursor".to_vec()) +} + +fn install_fixed_root(cache: &mut evm_fork_cache::EvmCache, root: B256) { + cache.set_account_proof_fetcher(Arc::new(move |requests, _block| { + requests + .into_iter() + .map(|(address, _slots)| { + ( + address, + Ok(AccountProof { + storage_hash: root, + balance: U256::ZERO, + nonce: 0, + code_hash: B256::ZERO, + slots: Vec::new(), + }), + ) + }) + .collect() + })); +} + +#[derive(serde::Serialize)] +struct EncodedRuntimeCheckpoint { + version: u32, + safe_head: Option, + finalized_head: Option, + health: CacheHealth, + pending_resyncs: Vec, + coverage_head: Option, + journal: Vec, + freshness: Option, + tracking: HashMap, + tracked_roots: HashMap, + root_gate_cadence: RootGateCadence, + last_gate_block: Option, + touched_since_gate: HashSet

, + metrics: CacheMetricsSnapshot, +} + +#[derive(serde::Serialize)] +struct EncodedBlockJournal { + block: BlockRef, + handler_ids: Vec, + rollback_diffs: Vec, +} + +#[derive(serde::Serialize)] +struct EncodedTrackedRoot { + last_root: B256, + last_block: u64, + balance: U256, + nonce: u64, + code_hash: B256, +} + +fn runtime_metadata_with_journal( + coverage: BlockRef, + journal: impl IntoIterator, +) -> DurableCheckpointMetadata { + let checkpoint = EncodedRuntimeCheckpoint { + version: 3, + safe_head: None, + finalized_head: None, + health: CacheHealth::Healthy, + pending_resyncs: Vec::new(), + coverage_head: Some(coverage), + journal: journal + .into_iter() + .map(|block| EncodedBlockJournal { + block, + handler_ids: Vec::new(), + rollback_diffs: Vec::new(), + }) + .collect(), + freshness: None, + tracking: HashMap::new(), + tracked_roots: HashMap::new(), + root_gate_cadence: RootGateCadence::every_n_blocks(16), + last_gate_block: None, + touched_since_gate: HashSet::new(), + metrics: CacheMetricsSnapshot::default(), + }; + let mut block = DurableCheckpointBlock::new(coverage.number, coverage.hash); + if let Some(parent_hash) = coverage.parent_hash { + block = block.with_parent_hash(parent_hash); + } + if let Some(timestamp) = coverage.timestamp { + block = block.with_timestamp(timestamp); + } + DurableCheckpointMetadata::new(identity(), block) + .with_runtime_checkpoint(bincode::serialize(&checkpoint).expect("encode runtime state")) +} + +fn assert_preview_and_resume_reject_without_mutation(invalid_metadata: &DurableCheckpointMetadata) { + let subscriber = ResumeRecordingSubscriber::default(); + let restore_calls = Arc::clone(&subscriber.restored); + let mut engine = ReactiveEngine::new( + ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + + let error = engine + .preview_durable_resume_position(invalid_metadata) + .expect_err("invalid runtime state must fail preview"); + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(_) + )); + assert!(engine.runtime().last_canonical_block().is_none()); + assert!(restore_calls.lock().expect("restore recorder").is_none()); + engine + .preview_durable_resume_position(&metadata(90, b"valid-after-failed-preview")) + .expect("failed semantic preview leaves the engine reusable"); + + let error = engine + .resume_from_durable_checkpoint(invalid_metadata) + .expect_err("invalid runtime state must fail restore"); + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(_) + )); + assert!(engine.runtime().last_canonical_block().is_none()); + assert!(restore_calls.lock().expect("restore recorder").is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_round_trip_restores_state_and_guards_identity() { + let path = temp_path("round_trip"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xa1); + let slot = U256::from(7); + let mut cache = setup_cache().await.expect("cache"); + let _ = cache.apply_update(&StateUpdate::slot(address, slot, U256::from(11))); + store + .save_async(&cache, metadata(42, b"delivery-42")) + .await + .expect("save checkpoint"); + + let _ = cache.apply_update(&StateUpdate::slot(address, slot, U256::from(99))); + let loaded = store.load().expect("load checkpoint").expect("checkpoint"); + assert_eq!(loaded.metadata().block.number, 42); + assert_eq!( + loaded.metadata().delivery_token.as_deref(), + Some(&b"delivery-42"[..]) + ); + assert_eq!( + loaded.metadata().subscriber_checkpoint.as_deref(), + Some(&b"provider-cursor"[..]) + ); + + let wrong = DurableCheckpointIdentity::new(1, "subscriber-a", "handlers-v2"); + let error = store + .load() + .expect("reload checkpoint") + .expect("checkpoint") + .restore_into(&mut cache, &wrong) + .expect_err("handler schema mismatch must fail closed"); + assert!(matches!( + error, + DurableCheckpointError::IdentityMismatch { .. } + )); + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(99)) + ); + + let restored = loaded + .restore_into(&mut cache, &identity()) + .expect("restore checkpoint"); + assert_eq!(restored.block.number, 42); + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(11)) + ); + assert_eq!( + cache.block(), + BlockId::from((B256::repeat_byte(42), Some(true))) + ); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn durable_checkpoint_file_is_owner_readable_and_writable_only() { + use std::os::unix::fs::PermissionsExt; + + let path = temp_path("private_permissions"); + let store = DurableCheckpointStore::new(&path); + let cache = setup_cache().await.expect("cache"); + store + .save_async(&cache, metadata(42, b"secret-delivery-token")) + .await + .expect("save private checkpoint"); + + assert_eq!( + fs::metadata(path) + .expect("checkpoint metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_metadata_normalizes_a_stale_cache_execution_context() { + let path = temp_path("metadata_context_alignment"); + let store = DurableCheckpointStore::new(&path); + let mut cache = setup_cache().await.expect("cache"); + cache.set_block(BlockId::number(7)); + cache.set_block_context(Some(7), Some(77)); + cache.set_timestamp(Some(1_600_000_007)); + cache.set_coinbase(Some(Address::repeat_byte(0x77))); + cache.set_prevrandao(Some(B256::repeat_byte(0x78))); + cache.set_block_gas_limit(Some(7_000_000)); + + store + .save(&cache, metadata(42, b"delivery-42")) + .expect("checkpoint"); + let loaded = store.load().expect("load").expect("checkpoint"); + let mut restored = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored, &identity()) + .expect("restore"); + + assert_eq!( + restored.block(), + BlockId::from((B256::repeat_byte(42), Some(true))) + ); + assert_eq!(restored.block_number(), Some(42)); + assert_eq!(restored.timestamp(), Some(1_700_000_042)); + assert_eq!(restored.basefee(), None); + assert_eq!(restored.coinbase(), None); + assert_eq!(restored.prevrandao(), None); + assert_eq!(restored.block_gas_limit(), None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exact_verified_header_environment_survives_checkpoint_round_trip() { + let path = temp_path("verified_header_environment"); + let store = DurableCheckpointStore::new(&path); + let mut cache = setup_cache().await.expect("cache"); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(header_batch(30_000_070, b"unused", None).into_records()), + ) + .expect("ingest verified header"); + + store + .save(&cache, metadata(70, b"delivery-70")) + .expect("save exact-header checkpoint"); + let loaded = store.load().expect("load").expect("checkpoint"); + let mut restored = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored, &identity()) + .expect("restore"); + + assert_eq!( + restored.block(), + BlockId::from((B256::repeat_byte(70), Some(true))) + ); + assert_eq!(restored.block_number(), Some(70)); + assert_eq!(restored.timestamp(), Some(1_700_000_070)); + assert_eq!(restored.basefee(), Some(70)); + assert_eq!(restored.coinbase(), Some(Address::repeat_byte(0x70))); + assert_eq!(restored.prevrandao(), Some(B256::repeat_byte(0x71))); + assert_eq!(restored.block_gas_limit(), Some(30_000_070)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn rejected_save_leaves_previous_checkpoint_authoritative() { + let path = temp_path("preserve_previous"); + let store = DurableCheckpointStore::new(&path); + let cache = setup_cache().await.expect("cache"); + store + .save(&cache, metadata(10, b"ten")) + .expect("initial checkpoint"); + + let invalid_identity = DurableCheckpointIdentity::new(2, "subscriber-a", "handlers-v1"); + let error = store + .save( + &cache, + DurableCheckpointMetadata::new( + invalid_identity, + DurableCheckpointBlock::new(11, B256::repeat_byte(11)) + .with_parent_hash(B256::repeat_byte(10)), + ), + ) + .expect_err("cross-chain checkpoint must be rejected"); + assert!(matches!( + error, + DurableCheckpointError::CacheChainMismatch { .. } + )); + assert_eq!( + store + .load() + .expect("load prior") + .expect("prior checkpoint") + .metadata() + .block + .number, + 10 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn corrupted_checkpoint_payload_is_rejected_before_decode() { + let path = temp_path("checksum_corruption"); + let store = DurableCheckpointStore::new(&path); + let cache = setup_cache().await.expect("cache"); + store + .save(&cache, metadata(12, b"twelve")) + .expect("checkpoint"); + + let mut bytes = fs::read(&path).expect("read checkpoint bytes"); + let payload_offset = 8 + std::mem::size_of::(); + bytes[payload_offset] ^= 0x80; + fs::write(&path, bytes).expect("corrupt checkpoint payload"); + + let error = match store.load() { + Err(error) => error, + Ok(_) => panic!("checksum must reject corruption"), + }; + assert!(matches!( + error, + DurableCheckpointError::ChecksumMismatch { .. } + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn load_rejects_checkpoint_over_the_configured_size_bound() { + let path = temp_path("bounded_load"); + let store = DurableCheckpointStore::new(&path); + let cache = setup_cache().await.expect("cache"); + store + .save(&cache, metadata(13, b"thirteen")) + .expect("checkpoint"); + let file_bytes = fs::metadata(&path).expect("checkpoint metadata").len(); + let bounded = + DurableCheckpointStore::new(&path).with_max_checkpoint_bytes(file_bytes.saturating_sub(1)); + + let error = match bounded.load() { + Err(error) => error, + Ok(_) => panic!("oversized file must be rejected"), + }; + assert!(matches!( + error, + DurableCheckpointError::CheckpointTooLarge { .. } + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn save_preflights_encoded_size_before_creating_a_checkpoint_file() { + let path = temp_path("bounded_save"); + let store = DurableCheckpointStore::new(&path).with_max_checkpoint_bytes(1); + let cache = setup_cache().await.expect("cache"); + + let error = store + .save(&cache, metadata(14, b"fourteen")) + .expect_err("encoded checkpoint must exceed one byte"); + assert!(matches!( + error, + DurableCheckpointError::CheckpointTooLarge { + bytes, + max_bytes: 1, + .. + } if bytes > 1 + )); + assert!( + !path.exists(), + "size rejection must occur before a temporary or destination file is installed" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelled_stale_writer_cannot_replace_a_newer_checkpoint() { + let path = temp_path("cancelled_writer_generation"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xaf); + let mut large = setup_cache().await.expect("large cache"); + install_mock_erc20(&mut large, address); + for slot in 0..25_000_u64 { + large + .db_mut() + .insert_account_storage(address, U256::from(slot), U256::from(slot + 1)) + .expect("seed large checkpoint"); + } + let small = setup_cache().await.expect("small cache"); + + let mut stale = Box::pin(store.save_async(&large, metadata(10, b"stale-ten"))); + assert!( + futures::poll!(&mut stale).is_pending(), + "large blocking write should have been dispatched" + ); + drop(stale); + + store + .save_async(&small, metadata(11, b"authoritative-eleven")) + .await + .expect("newer checkpoint"); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let loaded = store.load().expect("load").expect("checkpoint"); + assert_eq!(loaded.metadata().block.number, 11); + assert_eq!( + loaded.metadata().delivery_token.as_deref(), + Some(&b"authoritative-eleven"[..]) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn independently_constructed_stores_share_path_writer_ordering() { + let path = temp_path("independent_store_generation"); + let stale_store = DurableCheckpointStore::new(&path); + let newer_store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xae); + let mut large = setup_cache().await.expect("large cache"); + install_mock_erc20(&mut large, address); + for slot in 0..20_000_u64 { + large + .db_mut() + .insert_account_storage(address, U256::from(slot), U256::from(slot + 1)) + .expect("seed large checkpoint"); + } + let small = setup_cache().await.expect("small cache"); + + let mut stale = Box::pin(stale_store.save_async(&large, metadata(20, b"stale-twenty"))); + assert!(futures::poll!(&mut stale).is_pending()); + drop(stale); + newer_store + .save_async(&small, metadata(21, b"newer-twenty-one")) + .await + .expect("newer checkpoint"); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let loaded = newer_store.load().expect("load").expect("checkpoint"); + assert_eq!(loaded.metadata().block.number, 21); +} + +#[test] +fn lexically_equivalent_nonexistent_parent_paths_share_one_store_identity() { + let path = temp_path("lexical_store_identity"); + let parent = path.parent().expect("checkpoint parent"); + let file_name = path.file_name().expect("checkpoint file name"); + let aliased = parent + .join("parent-that-does-not-need-to-exist") + .join("..") + .join(file_name); + + assert_eq!( + DurableCheckpointStore::new(&path), + DurableCheckpointStore::new(aliased), + "lexically equivalent checkpoint paths must share writer ordering" + ); +} + +#[test] +fn store_identity_is_stable_when_a_missing_parent_is_created() { + let path = temp_path("missing_parent_store_identity") + .parent() + .expect("temp root") + .join("later") + .join("nested") + .join("checkpoint.bin"); + let before = DurableCheckpointStore::new(&path); + fs::create_dir_all(path.parent().expect("checkpoint parent")).expect("create parent suffix"); + let after = DurableCheckpointStore::new(&path); + + assert_eq!(before, after); + assert_eq!(before.path(), after.path()); +} + +#[cfg(unix)] +#[test] +fn store_identity_preserves_symlink_parent_semantics() { + use std::os::unix::fs::symlink; + + let root = temp_path("symlink_parent_store_identity") + .parent() + .expect("temp root") + .to_path_buf(); + let target = root.join("physical").join("nested"); + fs::create_dir_all(&target).expect("create symlink target"); + let link = root.join("link"); + symlink(&target, &link).expect("create directory symlink"); + + let through_symlink = link.join("..").join("checkpoint.bin"); + let physical = target + .parent() + .expect("physical parent") + .join("checkpoint.bin"); + let lexically_popped = root.join("checkpoint.bin"); + + assert_eq!( + DurableCheckpointStore::new(through_symlink), + DurableCheckpointStore::new(physical), + "OS symlink/.. resolution must identify the physical target" + ); + assert_ne!( + DurableCheckpointStore::new(root.join("link").join("..").join("checkpoint.bin")), + DurableCheckpointStore::new(lexically_popped), + "normalization must not lexically erase a symlink before resolving it" + ); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_save_replaces_destination_symlink_without_following_it() { + use std::os::unix::fs::symlink; + + let link_path = temp_path("destination_symlink"); + let target_path = link_path + .parent() + .expect("checkpoint parent") + .join("outside-target.bin"); + fs::write(&target_path, b"do not replace through link").expect("write symlink target"); + symlink(&target_path, &link_path).expect("create destination symlink"); + let store = DurableCheckpointStore::new(&link_path); + let cache = setup_cache().await.expect("cache"); + + store + .save(&cache, metadata(22, b"replace-link-entry")) + .expect("save checkpoint over symlink entry"); + + assert!( + !fs::symlink_metadata(&link_path) + .expect("destination metadata") + .file_type() + .is_symlink(), + "atomic replacement must replace the symlink entry itself" + ); + assert_eq!( + fs::read(&target_path).expect("original target"), + b"do not replace through link" + ); + assert_eq!( + store + .load() + .expect("load") + .expect("checkpoint") + .metadata() + .block + .number, + 22 + ); +} + +struct CountingWriter { + calls: Arc, + address: Address, + slot: U256, +} + +impl ReactiveHandler for CountingWriter { + fn id(&self) -> HandlerId { + HandlerId::new("writer") + } + + fn interests(&self) -> Vec> { + vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(self.address), + local_matcher: None, + route_key: Some(RouteKeySpec::EmitterAddress), + })] + } + + fn handle( + &self, + _ctx: &ReactiveContext, + input: &ReactiveInput, + _state: &dyn StateView, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + let ReactiveInput::Log(log) = input else { + return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect)); + }; + Ok(HandlerOutcome { + effects: vec![ReactiveEffect::StateUpdate(StateUpdate::slot( + log.address(), + self.slot, + U256::from(123), + ))], + quality: StateEffectQuality::ExactFromInput, + tags: Vec::new(), + }) + } +} + +struct OrderingSubscriber { + batches: VecDeque>, + polls: Arc, + acknowledgements: Arc, + checkpoint_path: PathBuf, + ack_saw_checkpoint: Arc, +} + +struct ToggleAckSubscriber { + batch: Option>, + fail_acknowledgement: bool, + acknowledgements: Arc, +} + +struct CountingHook(Arc); + +fn durable_capabilities() -> SubscriberCapabilities { + SubscriberCapabilities::new([SubscriberCapability::DurableReplay]) +} + +impl ReactiveHook for CountingHook { + fn on_report(&self, _report: Arc>) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +impl EventSubscriber for ToggleAckSubscriber { + fn capabilities(&self) -> SubscriberCapabilities { + durable_capabilities() + } + + fn register_interests( + &mut self, + _interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { Ok(()) }) + } + + fn next_batch(&mut self) -> SubscriberNextBatch<'_, Ethereum> { + Box::pin(async move { Ok(self.batch.take()) }) + } + + fn acknowledge_delivery( + &mut self, + _token: SubscriberDeliveryToken, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if self.fail_acknowledgement { + return Err(evm_fork_cache::reactive::SubscriberError::InvalidConfig( + "forced crash-window acknowledgement failure", + )); + } + self.acknowledgements.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + } +} + +#[derive(Default)] +struct ResumeRecordingSubscriber { + restored: Arc>>, + chain_id: Option, +} + +impl EventSubscriber for ResumeRecordingSubscriber { + fn chain_id(&self) -> Option { + self.chain_id + } + + fn capabilities(&self) -> SubscriberCapabilities { + durable_capabilities() + } + + fn register_interests( + &mut self, + _interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { Ok(()) }) + } + + fn next_batch(&mut self) -> SubscriberNextBatch<'_, Ethereum> { + Box::pin(async { Ok(None) }) + } + + fn restore_position( + &mut self, + position: &SubscriberResumePosition, + ) -> Result<(), evm_fork_cache::reactive::SubscriberError> { + *self.restored.lock().expect("restore recorder lock") = Some(position.clone()); + Ok(()) + } +} + +struct EphemeralResumeSubscriber { + restore_calls: Arc, +} + +impl EventSubscriber for EphemeralResumeSubscriber { + fn register_interests( + &mut self, + _interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { Ok(()) }) + } + + fn next_batch(&mut self) -> SubscriberNextBatch<'_, Ethereum> { + Box::pin(async { Ok(None) }) + } + + fn restore_position( + &mut self, + _position: &SubscriberResumePosition, + ) -> Result<(), evm_fork_cache::reactive::SubscriberError> { + self.restore_calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +#[derive(Default)] +struct RejectingRestoreSubscriber; + +impl EventSubscriber for RejectingRestoreSubscriber { + fn capabilities(&self) -> SubscriberCapabilities { + durable_capabilities() + } + + fn register_interests( + &mut self, + _interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { Ok(()) }) + } + + fn next_batch(&mut self) -> SubscriberNextBatch<'_, Ethereum> { + Box::pin(async { Ok(None) }) + } + + fn restore_position( + &mut self, + _position: &SubscriberResumePosition, + ) -> Result<(), evm_fork_cache::reactive::SubscriberError> { + Err(evm_fork_cache::reactive::SubscriberError::InvalidConfig( + "reject restore for atomicity test", + )) + } +} + +impl EventSubscriber for OrderingSubscriber { + fn capabilities(&self) -> SubscriberCapabilities { + durable_capabilities() + } + + fn register_interests( + &mut self, + _interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { Ok(()) }) + } + + fn next_batch(&mut self) -> SubscriberNextBatch<'_, Ethereum> { + self.polls.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { Ok(self.batches.pop_front()) }) + } + + fn acknowledge_delivery( + &mut self, + _token: SubscriberDeliveryToken, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + self.ack_saw_checkpoint + .store(self.checkpoint_path.is_file(), Ordering::SeqCst); + self.acknowledgements.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + } +} + +fn batch(address: Address, block_number: u64, token: &[u8]) -> ReactiveInputBatch { + let block = BlockRef { + number: block_number, + hash: B256::repeat_byte(block_number as u8), + parent_hash: Some(B256::repeat_byte(block_number.saturating_sub(1) as u8)), + timestamp: Some(1_700_000_000 + block_number), + }; + let log = Log { + inner: PrimitiveLog::new_unchecked(address, vec![keccak256(b"Event()")], Bytes::new()), + block_hash: Some(block.hash), + block_number: Some(block.number), + block_timestamp: block.timestamp, + transaction_hash: Some(B256::repeat_byte(0xcc)), + transaction_index: Some(0), + log_index: Some(0), + removed: false, + }; + ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(log), + ReactiveContext { + chain_id: Some(1), + source: InputSource::Subscription, + chain_status: ChainStatus::Included { + block, + confirmations: 0, + }, + block: Some(block), + transaction_index: Some(0), + log_index: Some(0), + }, + )]) + .with_delivery_token(SubscriberDeliveryToken::new(token.to_vec())) +} + +fn batch_for_block( + address: Address, + block: BlockRef, + token: &[u8], + removed: bool, +) -> ReactiveInputBatch { + let log = Log { + inner: PrimitiveLog::new_unchecked(address, vec![keccak256(b"Event()")], Bytes::new()), + block_hash: Some(block.hash), + block_number: Some(block.number), + block_timestamp: block.timestamp, + transaction_hash: Some(B256::repeat_byte(0xdd)), + transaction_index: Some(0), + log_index: Some(0), + removed, + }; + let chain_status = if removed { + ChainStatus::Reorged { + dropped_from: block, + } + } else { + ChainStatus::Included { + block, + confirmations: 0, + } + }; + ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(log), + ReactiveContext { + chain_id: Some(1), + source: InputSource::Subscription, + chain_status, + block: Some(block), + transaction_index: Some(0), + log_index: Some(0), + }, + )]) + .with_delivery_token(SubscriberDeliveryToken::new(token.to_vec())) +} + +fn removed_record_for_block( + address: Address, + block: BlockRef, + log_index: u64, +) -> ReactiveInputRecord { + let log = Log { + inner: PrimitiveLog::new_unchecked( + address, + vec![keccak256(b"RemovedEvent()")], + Bytes::new(), + ), + block_hash: Some(block.hash), + block_number: Some(block.number), + block_timestamp: block.timestamp, + transaction_hash: Some(B256::repeat_byte(0xee)), + transaction_index: Some(0), + log_index: Some(log_index), + removed: true, + }; + ReactiveInputRecord::new( + ReactiveInput::Log(log), + ReactiveContext { + chain_id: Some(1), + source: InputSource::Subscription, + chain_status: ChainStatus::Reorged { + dropped_from: block, + }, + block: Some(block), + transaction_index: Some(0), + log_index: Some(log_index), + }, + ) +} + +fn header_batch( + gas_limit: u64, + token: &[u8], + commitment: Option, +) -> ReactiveInputBatch { + let block = BlockRef { + number: 70, + hash: B256::repeat_byte(70), + parent_hash: Some(B256::repeat_byte(69)), + timestamp: Some(1_700_000_070), + }; + let header = Header { + hash: block.hash, + inner: alloy_consensus::Header { + number: block.number, + parent_hash: block.parent_hash.expect("parent"), + timestamp: block.timestamp.expect("timestamp"), + base_fee_per_gas: Some(70), + beneficiary: Address::repeat_byte(0x70), + mix_hash: B256::repeat_byte(0x71), + gas_limit, + ..Default::default() + }, + total_difficulty: None, + size: None, + }; + let batch = ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::BlockHeader(header), + ReactiveContext { + chain_id: Some(1), + source: InputSource::Subscription, + chain_status: ChainStatus::Included { + block, + confirmations: 0, + }, + block: Some(block), + transaction_index: None, + log_index: None, + }, + )]) + .with_delivery_token(SubscriberDeliveryToken::new(token.to_vec())); + match commitment { + Some(commitment) => batch.with_payload_commitment(commitment), + None => batch, + } +} + +fn direct_batch(address: Address, block_number: u64) -> ReactiveInputBatch { + ReactiveInputBatch::new(batch(address, block_number, b"unused").into_records()) +} + +fn engine( + subscriber: OrderingSubscriber, + calls: Arc, + address: Address, + slot: U256, +) -> ReactiveEngine { + let mut runtime = ReactiveRuntime::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(CountingWriter { + calls, + address, + slot, + })) + .expect("register writer"); + ReactiveEngine::new(runtime, subscriber) +} + +async fn persisted_two_block_runtime_metadata(tag: &str) -> DurableCheckpointMetadata { + let path = temp_path(tag); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xbd); + let slot = U256::from(13); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 120, b"delivery-120"), + batch(address, 121, b"delivery-121").with_subscriber_checkpoint( + SubscriberCheckpoint::new(b"provider-after-121".to_vec()), + ), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut engine = engine(subscriber, Arc::new(AtomicUsize::new(0)), address, slot); + let mut cache = setup_cache().await.expect("cache"); + for _ in 0..2 { + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("checkpoint") + .expect("batch"); + } + store + .load() + .expect("load") + .expect("checkpoint") + .metadata() + .clone() +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_resume_restores_subscriber_position_and_canonical_history() { + let path = temp_path("subscriber_resume_position"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xbe); + let slot = U256::from(14); + let first_subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 120, b"delivery-120"), + batch(address, 121, b"delivery-121").with_subscriber_checkpoint( + SubscriberCheckpoint::new(b"provider-after-121".to_vec()), + ), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut first = engine( + first_subscriber, + Arc::new(AtomicUsize::new(0)), + address, + slot, + ); + let mut cache = setup_cache().await.expect("cache"); + for _ in 0..2 { + first + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("checkpoint") + .expect("batch"); + } + + let loaded = store.load().expect("load").expect("checkpoint"); + let metadata = loaded.metadata().clone(); + let mut restored_cache = setup_cache().await.expect("restored cache"); + let recorder = ResumeRecordingSubscriber::default(); + let restored = recorder.restored.clone(); + let mut resumed = + ReactiveEngine::new(ReactiveRuntime::new(ReactiveConfig::default()), recorder); + let preview = resumed + .preview_durable_resume_position(&metadata) + .expect("preview the exact subscriber restore position"); + let restored_metadata = resumed + .restore_durable_checkpoint(&mut restored_cache, loaded, &identity()) + .expect("atomically restore cache, engine, and subscriber position"); + assert_eq!(restored_metadata, metadata); + + let position = restored + .lock() + .expect("restore recorder lock") + .clone() + .expect("subscriber restore hook"); + assert_eq!( + preview, position, + "the public preview and synchronous restore hook must share one exact position" + ); + assert_eq!(position.coverage_head.number, 121); + assert_eq!(position.chain_id, 1); + assert_eq!( + position + .canonical_history + .iter() + .map(|block| block.number) + .collect::>(), + vec![120, 121] + ); + assert_eq!( + position + .delivery_token + .as_ref() + .map(|token| token.as_bytes()), + Some(&b"delivery-121"[..]) + ); + assert_eq!( + position + .subscriber_checkpoint + .as_ref() + .map(|checkpoint| checkpoint.as_bytes()), + Some(&b"provider-after-121"[..]) + ); + resumed + .ingest_batch(&mut restored_cache, direct_batch(address, 122)) + .expect("continue from restored coverage"); + assert_eq!(resumed.runtime().metrics().missed_ranges, 0); + assert_eq!( + resumed + .runtime() + .last_canonical_block() + .expect("coverage advances") + .number, + 122 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn preview_and_restore_apply_the_same_configured_journal_retention() { + let metadata = persisted_two_block_runtime_metadata("preview_journal_retention").await; + + for (journal_depth, expected_history) in [(1, vec![121]), (0, Vec::new())] { + let config = ReactiveConfig { + journal_depth, + ..ReactiveConfig::default() + }; + let recorder = ResumeRecordingSubscriber::default(); + let restored = Arc::clone(&recorder.restored); + let mut engine = ReactiveEngine::new(ReactiveRuntime::new(config), recorder); + + let preview = engine + .preview_durable_resume_position(&metadata) + .expect("preview retained history"); + assert_eq!( + preview + .canonical_history + .iter() + .map(|block| block.number) + .collect::>(), + expected_history + ); + + engine + .resume_from_durable_checkpoint(&metadata) + .expect("restore retained history"); + assert_eq!( + restored.lock().expect("restore recorder lock").as_ref(), + Some(&preview) + ); + } +} + +#[test] +fn preview_and_restore_retain_the_exact_final_sixty_four_of_sixty_five_entries() { + let history = (100..=164) + .map(|number| BlockRef { + number, + hash: B256::repeat_byte(number as u8), + parent_hash: Some(B256::repeat_byte(number.saturating_sub(1) as u8)), + timestamp: Some(1_700_000_000 + number), + }) + .collect::>(); + let metadata = + runtime_metadata_with_journal(*history.last().expect("non-empty history"), history.clone()); + let subscriber = ResumeRecordingSubscriber::default(); + let restored = Arc::clone(&subscriber.restored); + let mut engine = ReactiveEngine::new( + ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + + let preview = engine + .preview_durable_resume_position(&metadata) + .expect("preview default retained history"); + assert_eq!(preview.canonical_history, history[1..]); + assert_eq!(preview.canonical_history.len(), 64); + + engine + .resume_from_durable_checkpoint(&metadata) + .expect("restore default retained history"); + assert_eq!( + restored.lock().expect("restore recorder").as_ref(), + Some(&preview) + ); +} + +#[test] +fn preview_and_restore_share_the_legacy_metadata_only_fallback() { + let metadata = metadata(90, b"legacy-delivery"); + + for (journal_depth, expected_history) in [(64, vec![90]), (0, Vec::new())] { + let config = ReactiveConfig { + journal_depth, + ..ReactiveConfig::default() + }; + let recorder = ResumeRecordingSubscriber::default(); + let restored = Arc::clone(&recorder.restored); + let mut engine = ReactiveEngine::new(ReactiveRuntime::new(config), recorder); + + let preview = engine + .preview_durable_resume_position(&metadata) + .expect("preview metadata-only fallback"); + assert_eq!( + preview + .canonical_history + .iter() + .map(|block| block.number) + .collect::>(), + expected_history + ); + + engine + .resume_from_durable_checkpoint(&metadata) + .expect("restore metadata-only fallback"); + assert_eq!( + restored.lock().expect("restore recorder lock").as_ref(), + Some(&preview) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_runtime_bytes_fail_preview_without_mutating_restore_state() { + let valid = persisted_two_block_runtime_metadata("preview_invalid_runtime_bytes").await; + let mut malformed = valid.clone(); + malformed.runtime_checkpoint = Some(b"not-a-runtime-checkpoint".to_vec()); + let mut trailing = valid.clone(); + trailing + .runtime_checkpoint + .as_mut() + .expect("real engine checkpoint") + .push(0xff); + + for candidate in [malformed, trailing] { + let subscriber = ResumeRecordingSubscriber::default(); + let restore_calls = Arc::clone(&subscriber.restored); + let engine = ReactiveEngine::new( + ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + + let error = engine + .preview_durable_resume_position(&candidate) + .expect_err("invalid runtime bytes must fail preview"); + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(_) + )); + assert!(engine.runtime().last_canonical_block().is_none()); + assert!(restore_calls.lock().expect("restore recorder").is_none()); + + engine + .preview_durable_resume_position(&valid) + .expect("failed preview leaves the engine reusable"); + } +} + +#[test] +fn preview_rejects_a_mismatched_subscriber_chain_without_restore_mutation() { + let subscriber = ResumeRecordingSubscriber { + chain_id: Some(2), + ..ResumeRecordingSubscriber::default() + }; + let restore_calls = Arc::clone(&subscriber.restored); + let engine = ReactiveEngine::new( + ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + + let error = engine + .preview_durable_resume_position(&metadata(90, b"wrong-chain")) + .expect_err("subscriber and checkpoint chains must match"); + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::SubscriberChainMismatch { + subscriber_chain_id: 2, + checkpoint_chain_id: 1, + } + )); + assert!(engine.runtime().last_canonical_block().is_none()); + assert!(restore_calls.lock().expect("restore recorder").is_none()); +} + +#[test] +fn preview_rejects_an_ephemeral_subscriber_without_invoking_restore() { + let restore_calls = Arc::new(AtomicUsize::new(0)); + let engine = ReactiveEngine::new( + ReactiveRuntime::::new(ReactiveConfig::default()), + EphemeralResumeSubscriber { + restore_calls: Arc::clone(&restore_calls), + }, + ); + + let error = engine + .preview_durable_resume_position(&metadata(90, b"ephemeral")) + .expect_err("durable restore requires durable replay"); + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::SubscriberNotDurable + )); + assert!(engine.runtime().last_canonical_block().is_none()); + assert_eq!(restore_calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn atomic_restore_rolls_cache_back_when_subscriber_rejects_position() { + let path = temp_path("atomic_restore_subscriber_failure"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xd4); + let slot = U256::from(9); + let mut saved_cache = setup_cache().await.expect("saved cache"); + let _ = saved_cache.apply_update(&StateUpdate::slot(address, slot, U256::from(11))); + store + .save(&saved_cache, metadata(42, b"delivery-42")) + .expect("save checkpoint"); + + let mut live_cache = setup_cache().await.expect("live cache"); + let _ = live_cache.apply_update(&StateUpdate::slot(address, slot, U256::from(99))); + let loaded = store.load().expect("load").expect("checkpoint"); + let mut engine = ReactiveEngine::new( + ReactiveRuntime::::new(ReactiveConfig::default()), + RejectingRestoreSubscriber, + ); + + let error = engine + .restore_durable_checkpoint(&mut live_cache, loaded, &identity()) + .expect_err("subscriber failure must abort the complete restore"); + + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::Subscriber(_) + )); + assert_eq!( + live_cache.cached_storage_value(address, slot), + Some(U256::from(99)) + ); + assert!(engine.runtime().last_canonical_block().is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_resume_rejects_a_control_only_active_runtime() { + let mut cache = setup_cache().await.expect("cache"); + let active = BlockRef { + number: 100, + hash: B256::repeat_byte(100), + parent_hash: Some(B256::repeat_byte(99)), + timestamp: Some(1_700_000_100), + }; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::CanonicalProgress(active)]), + ) + .expect("advance control-only coverage"); + assert!(runtime.journaled_handler_ids().is_empty()); + let subscriber = ResumeRecordingSubscriber::default(); + let restore_calls = Arc::clone(&subscriber.restored); + let mut engine = ReactiveEngine::new(runtime, subscriber); + + let error = engine + .preview_durable_resume_position(&metadata(90, b"older")) + .expect_err("preview cannot silently rewind active coverage"); + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::ActiveRuntime + )); + assert_eq!(engine.runtime().last_canonical_block(), Some(active)); + assert!(restore_calls.lock().expect("restore recorder").is_none()); + + let error = engine + .resume_from_durable_checkpoint(&metadata(90, b"older")) + .expect_err("active coverage cannot be silently rewound"); + + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::ActiveRuntime + )); + assert_eq!(engine.runtime().last_canonical_block(), Some(active)); + assert!(restore_calls.lock().expect("restore recorder").is_none()); +} + +#[test] +fn checkpoint_resume_rejects_semantically_invalid_runtime_state_before_mutation() { + let block_120 = BlockRef { + number: 120, + hash: B256::repeat_byte(120), + parent_hash: Some(B256::repeat_byte(119)), + timestamp: Some(1_700_000_120), + }; + let block_121 = BlockRef { + number: 121, + hash: B256::repeat_byte(121), + parent_hash: Some(block_120.hash), + timestamp: Some(1_700_000_121), + }; + let malformed = EncodedRuntimeCheckpoint { + version: 3, + safe_head: Some(block_120), + finalized_head: Some(block_121), + health: CacheHealth::Healthy, + pending_resyncs: Vec::new(), + coverage_head: Some(block_121), + journal: vec![ + EncodedBlockJournal { + block: block_121, + handler_ids: Vec::new(), + rollback_diffs: Vec::new(), + }, + EncodedBlockJournal { + block: block_120, + handler_ids: Vec::new(), + rollback_diffs: Vec::new(), + }, + ], + freshness: None, + tracking: HashMap::new(), + tracked_roots: HashMap::new(), + root_gate_cadence: RootGateCadence::every_n_blocks(16), + last_gate_block: None, + touched_since_gate: HashSet::new(), + metrics: CacheMetricsSnapshot::default(), + }; + let metadata = DurableCheckpointMetadata::new( + identity(), + DurableCheckpointBlock::new(block_121.number, block_121.hash) + .with_parent_hash(block_121.parent_hash.expect("test block has parent")) + .with_timestamp(block_121.timestamp.expect("test block has timestamp")), + ) + .with_runtime_checkpoint(bincode::serialize(&malformed).expect("encode malformed state")); + let subscriber = ResumeRecordingSubscriber::default(); + let restore_calls = subscriber.restored.clone(); + let mut engine = ReactiveEngine::new( + ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + + let error = engine + .preview_durable_resume_position(&metadata) + .expect_err("semantic contradictions must fail preview"); + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(_) + )); + assert!(engine.runtime().last_canonical_block().is_none()); + assert!(restore_calls.lock().expect("restore recorder").is_none()); + + let error = engine + .resume_from_durable_checkpoint(&metadata) + .expect_err("semantic contradictions must fail closed"); + + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(_) + )); + assert!(engine.runtime().last_canonical_block().is_none()); + assert!(restore_calls.lock().expect("restore recorder").is_none()); +} + +#[test] +fn checkpoint_restore_rejects_same_height_journal_parent_conflict() { + let coverage = BlockRef { + number: 121, + hash: B256::repeat_byte(121), + parent_hash: Some(B256::repeat_byte(120)), + timestamp: Some(1_700_000_121), + }; + let conflicting_tail = BlockRef { + parent_hash: Some(B256::repeat_byte(0xee)), + ..coverage + }; + let metadata = runtime_metadata_with_journal(coverage, [conflicting_tail]); + + assert_preview_and_resume_reject_without_mutation(&metadata); +} + +#[test] +fn checkpoint_restore_rejects_same_height_journal_timestamp_conflict() { + let coverage = BlockRef { + number: 121, + hash: B256::repeat_byte(121), + parent_hash: Some(B256::repeat_byte(120)), + timestamp: Some(1_700_000_121), + }; + let conflicting_tail = BlockRef { + timestamp: Some(1_800_000_121), + ..coverage + }; + let metadata = runtime_metadata_with_journal(coverage, [conflicting_tail]); + + assert_preview_and_resume_reject_without_mutation(&metadata); +} + +#[test] +fn checkpoint_restore_rejects_coverage_that_does_not_descend_from_adjacent_journal_tail() { + let tail = BlockRef { + number: 120, + hash: B256::repeat_byte(120), + parent_hash: Some(B256::repeat_byte(119)), + timestamp: Some(1_700_000_120), + }; + let coverage = BlockRef { + number: 121, + hash: B256::repeat_byte(121), + parent_hash: Some(B256::repeat_byte(0xee)), + timestamp: Some(1_700_000_121), + }; + let metadata = runtime_metadata_with_journal(coverage, [tail]); + + assert_preview_and_resume_reject_without_mutation(&metadata); +} + +#[test] +fn checkpoint_restore_accepts_one_sided_same_height_optional_metadata() { + let full = BlockRef { + number: 121, + hash: B256::repeat_byte(121), + parent_hash: Some(B256::repeat_byte(120)), + timestamp: Some(1_700_000_121), + }; + let sparse = BlockRef { + parent_hash: None, + timestamp: None, + ..full + }; + + for (coverage, tail) in [(full, sparse), (sparse, full)] { + let metadata = runtime_metadata_with_journal(coverage, [tail]); + let subscriber = ResumeRecordingSubscriber::default(); + let restored = Arc::clone(&subscriber.restored); + let mut engine = ReactiveEngine::new( + ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + + let preview = engine + .preview_durable_resume_position(&metadata) + .expect("one-sided optional metadata is compatible"); + engine + .resume_from_durable_checkpoint(&metadata) + .expect("compatible runtime tail restores"); + assert_eq!( + restored.lock().expect("restore recorder").as_ref(), + Some(&preview) + ); + } +} + +#[test] +fn checkpoint_resume_rejects_finality_ahead_of_canonical_coverage() { + let coverage = BlockRef { + number: 121, + hash: B256::repeat_byte(121), + parent_hash: Some(B256::repeat_byte(120)), + timestamp: Some(1_700_000_121), + }; + let future_safe = BlockRef { + number: 122, + hash: B256::repeat_byte(122), + parent_hash: Some(coverage.hash), + timestamp: Some(1_700_000_122), + }; + let malformed = EncodedRuntimeCheckpoint { + version: 3, + safe_head: Some(future_safe), + finalized_head: None, + health: CacheHealth::Healthy, + pending_resyncs: Vec::new(), + coverage_head: Some(coverage), + journal: Vec::new(), + freshness: None, + tracking: HashMap::new(), + tracked_roots: HashMap::new(), + root_gate_cadence: RootGateCadence::every_n_blocks(16), + last_gate_block: None, + touched_since_gate: HashSet::new(), + metrics: CacheMetricsSnapshot::default(), + }; + let metadata = DurableCheckpointMetadata::new( + identity(), + DurableCheckpointBlock::new(coverage.number, coverage.hash) + .with_parent_hash(coverage.parent_hash.expect("coverage parent")) + .with_timestamp(coverage.timestamp.expect("coverage timestamp")), + ) + .with_runtime_checkpoint(bincode::serialize(&malformed).expect("encode malformed state")); + let subscriber = ResumeRecordingSubscriber::default(); + let restore_calls = subscriber.restored.clone(); + let mut engine = ReactiveEngine::new( + ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + + let error = engine + .resume_from_durable_checkpoint(&metadata) + .expect_err("future finality must fail closed"); + + assert!(matches!( + error, + evm_fork_cache::reactive::ReactiveCheckpointRestoreError::InvalidRuntimeCheckpoint(_) + )); + assert!(restore_calls.lock().expect("restore recorder").is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_engine_persists_opaque_subscriber_checkpoint() { + let path = temp_path("subscriber_checkpoint"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xb0); + let slot = U256::from(4); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 49, b"delivery-49").with_subscriber_checkpoint( + SubscriberCheckpoint::new(b"hypersync:opaque-cursor".to_vec()), + ), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut engine = engine(subscriber, Arc::new(AtomicUsize::new(0)), address, slot); + let mut cache = setup_cache().await.expect("cache"); + + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("checkpoint commit") + .expect("batch"); + + let loaded = store.load().expect("load").expect("checkpoint"); + assert_eq!( + loaded.metadata().subscriber_checkpoint.as_deref(), + Some(&b"hypersync:opaque-cursor"[..]) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn empty_catchup_barrier_advances_checkpoint_coverage_anchor() { + let path = temp_path("empty_barrier"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xb2); + let slot = U256::from(6); + let certified = BlockRef { + number: 100, + hash: B256::repeat_byte(100), + parent_hash: Some(B256::repeat_byte(99)), + timestamp: Some(1_700_000_100), + }; + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 90, b"delivery-90"), + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Barrier { + id: b"empty-91-100".to_vec(), + block: Some(certified), + }]) + .with_delivery_token(SubscriberDeliveryToken::new(b"delivery-100".to_vec())) + .with_subscriber_checkpoint(SubscriberCheckpoint::new( + b"cursor-after-100".to_vec(), + )), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut engine = engine(subscriber, Arc::new(AtomicUsize::new(0)), address, slot); + let mut cache = setup_cache().await.expect("cache"); + // Compact event progress has no full header. Seed a deliberately stale EVM + // context to prove the checkpoint cannot pair it with the newer exact pin. + cache.set_block_context(Some(80), Some(80)); + cache.set_timestamp(Some(1_700_000_080)); + cache.set_coinbase(Some(Address::repeat_byte(0x80))); + cache.set_prevrandao(Some(B256::repeat_byte(0x80))); + cache.set_block_gas_limit(Some(8_000_000)); + for _ in 0..2 { + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("checkpoint") + .expect("batch"); + } + + let loaded = store.load().expect("load").expect("checkpoint"); + assert_eq!(loaded.metadata().block.number, 100); + assert_eq!(loaded.metadata().block.hash, certified.hash); + let mut restored = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored, &identity()) + .expect("restore"); + assert_eq!( + restored.block(), + BlockId::from((certified.hash, Some(true))) + ); + assert_eq!(restored.block_number(), Some(certified.number)); + assert_eq!(restored.timestamp(), certified.timestamp); + assert_eq!(restored.basefee(), None); + assert_eq!(restored.coinbase(), None); + assert_eq!(restored.prevrandao(), None); + assert_eq!(restored.block_gas_limit(), None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tokenless_checkpoint_preserves_last_durable_replay_fence() { + let path = temp_path("tokenless_replay_fence"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xb3); + let slot = U256::from(8); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 110, b"delivery-110") + .with_subscriber_checkpoint(SubscriberCheckpoint::new(b"cursor-110".to_vec())), + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Barrier { + id: b"tokenless".to_vec(), + block: Some(BlockRef { + number: 111, + hash: B256::repeat_byte(111), + parent_hash: Some(B256::repeat_byte(110)), + timestamp: Some(1_700_000_111), + }), + }]) + .with_subscriber_checkpoint(SubscriberCheckpoint::new(b"cursor-111".to_vec())), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path.clone(), + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut first = engine(subscriber, Arc::new(AtomicUsize::new(0)), address, slot); + let mut cache = setup_cache().await.expect("cache"); + for _ in 0..2 { + first + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("checkpoint") + .expect("batch"); + } + let loaded = store.load().expect("load").expect("checkpoint"); + let restored_metadata = loaded.metadata().clone(); + assert_eq!( + restored_metadata.delivery_token.as_deref(), + Some(&b"delivery-110"[..]) + ); + assert_eq!( + restored_metadata.subscriber_checkpoint.as_deref(), + Some(&b"cursor-111"[..]) + ); + assert!(restored_metadata.delivery_witness.is_some()); + let mut restored_cache = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored_cache, &identity()) + .expect("restore"); + + let replay_calls = Arc::new(AtomicUsize::new(0)); + let replay_acks = Arc::new(AtomicUsize::new(0)); + let replay_subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 110, b"delivery-110") + .with_subscriber_checkpoint(SubscriberCheckpoint::new(b"cursor-110".to_vec()))]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&replay_acks), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut replay = engine(replay_subscriber, Arc::clone(&replay_calls), address, slot); + replay + .resume_from_durable_checkpoint(&restored_metadata) + .expect("resume"); + assert!(matches!( + replay + .next_ingest_checkpointed(&mut restored_cache, &store, &identity()) + .await + .expect("replay") + .expect("outcome"), + CheckpointedIngest::ReplayAcknowledged + )); + assert_eq!(replay_calls.load(Ordering::SeqCst), 0); + assert_eq!(replay_acks.load(Ordering::SeqCst), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_ingest_rejects_explicit_reorg_beyond_retained_journal() { + let path = temp_path("deep_reorg_fail_closed"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xbd); + let slot = U256::from(22); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let old_tip = BlockRef { + number: 3, + hash: B256::repeat_byte(3), + parent_hash: Some(B256::repeat_byte(2)), + timestamp: Some(1_700_000_003), + }; + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 1, b"delivery-1"), + batch(address, 2, b"delivery-2"), + batch(address, 3, b"delivery-3"), + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: BlockRef { + number: 1, + hash: B256::repeat_byte(1), + parent_hash: Some(B256::ZERO), + timestamp: Some(1_700_000_001), + }, + old_tip, + new_tip: BlockRef { + number: 3, + hash: B256::repeat_byte(0xf3), + parent_hash: Some(B256::repeat_byte(0xf2)), + timestamp: Some(1_700_000_003), + }, + }]) + .with_delivery_token(SubscriberDeliveryToken::new(b"reorg-1-3".to_vec())), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 2, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + for _ in 0..3 { + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("canonical checkpoint") + .expect("batch"); + } + let generation_before_reorg = cache.snapshot_generation(); + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("deep explicit reorg cannot become a durable partial rollback"); + assert!(matches!( + error, + ReactiveEngineError::CheckpointReorgOutsideJournal { + common_ancestor: 1, + oldest_journaled: Some(2), + journal_depth: 2, + } + )); + assert_eq!(cache.snapshot_generation(), generation_before_reorg); + assert_eq!(engine.runtime().last_canonical_block(), Some(old_tip)); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 3); + assert_eq!( + store + .load() + .expect("load") + .expect("checkpoint") + .metadata() + .block + .number, + 3 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_ingest_rejects_unknown_parent_replacement_before_mutation_or_ack() { + let path = temp_path("implicit_deep_reorg_fail_closed"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xc1); + let slot = U256::from(25); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let replacement = BlockRef { + number: 3, + hash: B256::repeat_byte(0xf3), + parent_hash: Some(B256::repeat_byte(0xf2)), + timestamp: Some(1_700_000_003), + }; + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 1, b"delivery-1"), + batch(address, 2, b"delivery-2"), + batch(address, 3, b"delivery-3"), + batch_for_block(address, replacement, b"implicit-replacement-3", false), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 2, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + for _ in 0..3 { + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("canonical checkpoint") + .expect("batch"); + } + let generation_before_reorg = cache.snapshot_generation(); + let old_tip = engine.runtime().last_canonical_block(); + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("an unproven implicit ancestor must not become durable"); + assert!(matches!( + error, + ReactiveEngineError::Runtime(ReactiveError::InvalidChainControl { .. }) + )); + assert_eq!(cache.snapshot_generation(), generation_before_reorg); + assert_eq!(engine.runtime().last_canonical_block(), old_tip); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 3); + assert_eq!( + store + .load() + .expect("load") + .expect("checkpoint") + .metadata() + .block + .hash, + B256::repeat_byte(3) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_ingest_rejects_aged_out_removed_log_before_mutation_or_ack() { + let path = temp_path("removed_deep_reorg_fail_closed"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xc2); + let slot = U256::from(26); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let dropped = BlockRef { + number: 1, + hash: B256::repeat_byte(1), + parent_hash: Some(B256::ZERO), + timestamp: Some(1_700_000_001), + }; + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 1, b"delivery-1"), + batch(address, 2, b"delivery-2"), + batch(address, 3, b"delivery-3"), + batch_for_block(address, dropped, b"removed-1", true), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 2, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + for _ in 0..3 { + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("canonical checkpoint") + .expect("batch"); + } + let generation_before_reorg = cache.snapshot_generation(); + let old_tip = engine.runtime().last_canonical_block(); + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("an aged-out removed log must not become durable"); + assert!(matches!( + error, + ReactiveEngineError::CheckpointReorgOutsideJournal { + common_ancestor: 0, + oldest_journaled: Some(2), + journal_depth: 2, + } + )); + assert_eq!(cache.snapshot_generation(), generation_before_reorg); + assert_eq!(engine.runtime().last_canonical_block(), old_tip); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 3); + assert_eq!( + store + .load() + .expect("load") + .expect("checkpoint") + .metadata() + .block + .number, + 3 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_removed_tip_uses_authenticated_parent_and_rejects_parentless_state() { + let path = temp_path("removed_tip_with_authenticated_parent"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xc3); + let slot = U256::from(28); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let dropped = BlockRef { + number: 1, + hash: B256::repeat_byte(1), + parent_hash: Some(B256::ZERO), + timestamp: Some(1_700_000_001), + }; + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 1, b"delivery-1"), + batch_for_block(address, dropped, b"removed-1", true), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 1, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("canonical checkpoint") + .expect("batch"); + assert!(matches!( + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("the exact removed tip authenticates its parent") + .expect("removed batch"), + CheckpointedIngest::Applied(_) + )); + let parent = BlockRef { + number: 0, + hash: B256::ZERO, + parent_hash: None, + timestamp: None, + }; + assert_eq!(engine.runtime().last_canonical_block(), Some(parent)); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 2); + let loaded = store + .load() + .expect("load parent checkpoint") + .expect("parent checkpoint"); + assert_eq!(loaded.metadata().block.number, 0); + assert_eq!(loaded.metadata().block.hash, B256::ZERO); + let restored_metadata = loaded.metadata().clone(); + let mut restored_cache = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored_cache, &identity()) + .expect("restore parent checkpoint"); + let mut restored_engine = ReactiveEngine::new( + ReactiveRuntime::::new(ReactiveConfig::default()), + ResumeRecordingSubscriber::default(), + ); + restored_engine + .resume_from_durable_checkpoint(&restored_metadata) + .expect("restore runtime at authenticated parent"); + assert_eq!( + restored_engine.runtime().last_canonical_block(), + Some(parent) + ); + + let parentless_path = temp_path("removed_tip_without_any_anchor"); + let parentless_store = DurableCheckpointStore::new(&parentless_path); + let parentless = BlockRef { + parent_hash: None, + ..dropped + }; + let parentless_acknowledgements = Arc::new(AtomicUsize::new(0)); + let parentless_subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch_for_block(address, parentless, b"parentless-1", false), + batch_for_block(address, parentless, b"parentless-removed-1", true), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&parentless_acknowledgements), + checkpoint_path: parentless_path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut parentless_runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 1, + ..ReactiveConfig::default() + }); + parentless_runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register parentless writer"); + let mut parentless_engine = ReactiveEngine::new(parentless_runtime, parentless_subscriber); + let mut parentless_cache = setup_cache().await.expect("parentless cache"); + parentless_engine + .next_ingest_checkpointed(&mut parentless_cache, &parentless_store, &identity()) + .await + .expect("parentless canonical checkpoint") + .expect("parentless canonical batch"); + let generation_before_reorg = parentless_cache.snapshot_generation(); + + let error = parentless_engine + .next_ingest_checkpointed(&mut parentless_cache, &parentless_store, &identity()) + .await + .expect_err("a parentless removed tip has no durable rollback anchor"); + assert!(matches!( + error, + ReactiveEngineError::CheckpointReorgOutsideJournal { + common_ancestor: 0, + oldest_journaled: Some(1), + journal_depth: 1, + } + )); + assert_eq!( + parentless_cache.snapshot_generation(), + generation_before_reorg + ); + assert_eq!( + parentless_engine.runtime().last_canonical_block(), + Some(parentless) + ); + assert_eq!(parentless_acknowledgements.load(Ordering::SeqCst), 1); + assert_eq!( + parentless_store + .load() + .expect("load") + .expect("checkpoint") + .metadata() + .block + .number, + 1 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_removed_tip_accepts_a_same_parent_replacement_in_the_same_batch() { + let path = temp_path("removed_tip_with_proven_replacement"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xc4); + let slot = U256::from(29); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let dropped = BlockRef { + number: 1, + hash: B256::repeat_byte(1), + parent_hash: Some(B256::ZERO), + timestamp: Some(1_700_000_001), + }; + let replacement = BlockRef { + number: 1, + hash: B256::repeat_byte(0xf1), + parent_hash: dropped.parent_hash, + timestamp: Some(1_700_000_002), + }; + let removed = batch_for_block(address, dropped, b"unused", true) + .into_records() + .pop() + .expect("removed record"); + let replacement_record = batch_for_block(address, replacement, b"unused", false) + .into_records() + .pop() + .expect("replacement record"); + let replacement_batch = ReactiveInputBatch::new(vec![replacement_record, removed]) + .with_delivery_token(SubscriberDeliveryToken::new(b"replacement-1".to_vec())); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 1, b"delivery-1"), replacement_batch]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 1, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + for _ in 0..2 { + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("same-parent replacement is durably recoverable") + .expect("batch"); + } + + assert_eq!(engine.runtime().last_canonical_block(), Some(replacement)); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 2); + assert_eq!( + store + .load() + .expect("load") + .expect("checkpoint") + .metadata() + .block + .hash, + replacement.hash + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_distinct_removals_share_the_first_rollback_and_remain_healthy() { + let path = temp_path("coalesced_removed_span"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xc6); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let block_11 = BlockRef { + number: 11, + hash: B256::repeat_byte(11), + parent_hash: Some(B256::repeat_byte(10)), + timestamp: Some(1_700_000_011), + }; + let block_12 = BlockRef { + number: 12, + hash: B256::repeat_byte(12), + parent_hash: Some(block_11.hash), + timestamp: Some(1_700_000_012), + }; + let removal_batch = ReactiveInputBatch::new(vec![ + removed_record_for_block(address, block_11, 0), + removed_record_for_block(address, block_11, 1), + removed_record_for_block(address, block_12, 2), + ]) + .with_delivery_token(SubscriberDeliveryToken::new(b"removed-span".to_vec())); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 10, b"delivery-10"), + batch(address, 11, b"delivery-11"), + batch(address, 12, b"delivery-12"), + removal_batch, + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 3, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot: U256::from(31), + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + for _ in 0..4 { + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("the complete removed span remains checkpointable") + .expect("batch"); + } + + assert_eq!( + engine + .runtime() + .last_canonical_block() + .map(|block| block.number), + Some(10) + ); + assert_eq!(engine.runtime().metrics().deep_reorgs, 0); + assert_eq!(engine.runtime().health(), CacheHealth::Healthy); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 4); + assert_eq!( + store + .load() + .expect("load") + .expect("checkpoint") + .metadata() + .block + .number, + 10 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_explicit_reorg_coalesces_removed_records_with_or_without_data_replacement() { + for include_replacement in [false, true] { + let path = temp_path(if include_replacement { + "explicit_removed_with_replacement" + } else { + "explicit_removed_without_replacement" + }); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(if include_replacement { 0xc8 } else { 0xc7 }); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let ancestor = BlockRef { + number: 20, + hash: B256::repeat_byte(20), + parent_hash: Some(B256::repeat_byte(19)), + timestamp: Some(1_700_000_020), + }; + let old_tip = BlockRef { + number: 21, + hash: B256::repeat_byte(21), + parent_hash: Some(ancestor.hash), + timestamp: Some(1_700_000_021), + }; + let replacement = BlockRef { + number: 21, + hash: B256::repeat_byte(0xf1), + parent_hash: Some(ancestor.hash), + timestamp: Some(1_700_000_022), + }; + let mut records = vec![removed_record_for_block(address, old_tip, 4)]; + if include_replacement { + records.extend( + batch_for_block( + address, + BlockRef { + parent_hash: None, + timestamp: None, + ..replacement + }, + b"unused", + false, + ) + .into_records(), + ); + } + let reorg_batch = ReactiveInputBatch::new(records) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip: replacement, + }]) + .with_delivery_token(SubscriberDeliveryToken::new(b"explicit-reorg".to_vec())); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 20, b"delivery-20"), + batch(address, 21, b"delivery-21"), + reorg_batch, + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 2, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot: U256::from(32), + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + for _ in 0..3 { + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("redundant post-control removal is checkpointable") + .expect("batch"); + } + assert_eq!( + engine.runtime().last_canonical_block(), + Some(if include_replacement { + replacement + } else { + ancestor + }) + ); + assert_eq!(engine.runtime().metrics().deep_reorgs, 0); + assert_eq!(engine.runtime().health(), CacheHealth::Healthy); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 3); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_removed_sole_tip_accepts_zero_event_progress_and_blockful_barrier() { + for use_barrier in [false, true] { + let path = temp_path(if use_barrier { + "removed_tip_blockful_barrier" + } else { + "removed_tip_progress" + }); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(if use_barrier { 0xca } else { 0xc9 }); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let dropped = BlockRef { + number: 1, + hash: B256::repeat_byte(1), + parent_hash: Some(B256::ZERO), + timestamp: Some(1_700_000_001), + }; + let replacement = BlockRef { + number: 1, + hash: B256::repeat_byte(0xf1), + parent_hash: dropped.parent_hash, + timestamp: Some(1_700_000_002), + }; + let progress = if use_barrier { + ChainControl::Barrier { + id: b"zero-event-replacement".to_vec(), + block: Some(replacement), + } + } else { + ChainControl::CanonicalProgress(replacement) + }; + let replacement_batch = + ReactiveInputBatch::new(vec![removed_record_for_block(address, dropped, 0)]) + .with_chain_id(1) + .with_chain_controls([progress]) + .with_delivery_token(SubscriberDeliveryToken::new( + b"zero-event-replacement".to_vec(), + )); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 1, b"delivery-1"), replacement_batch]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 1, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot: U256::from(33), + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + for _ in 0..2 { + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("same-parent zero-event replacement is durable") + .expect("batch"); + } + assert_eq!(engine.runtime().last_canonical_block(), Some(replacement)); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 2); + assert_eq!( + store + .load() + .expect("load") + .expect("checkpoint") + .metadata() + .block + .hash, + replacement.hash + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_removed_tip_rejects_a_different_parent_replacement_in_the_same_batch() { + let path = temp_path("removed_tip_with_unproven_replacement"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xc5); + let slot = U256::from(30); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let dropped = BlockRef { + number: 1, + hash: B256::repeat_byte(1), + parent_hash: Some(B256::ZERO), + timestamp: Some(1_700_000_001), + }; + let replacement = BlockRef { + number: 1, + hash: B256::repeat_byte(0xf1), + parent_hash: Some(B256::repeat_byte(0xf0)), + timestamp: Some(1_700_000_002), + }; + let removed = batch_for_block(address, dropped, b"unused", true) + .into_records() + .pop() + .expect("removed record"); + let replacement_record = batch_for_block(address, replacement, b"unused", false) + .into_records() + .pop() + .expect("replacement record"); + let replacement_batch = ReactiveInputBatch::new(vec![replacement_record, removed]) + .with_delivery_token(SubscriberDeliveryToken::new(b"replacement-1".to_vec())); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 1, b"delivery-1"), replacement_batch]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 1, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("canonical checkpoint") + .expect("batch"); + let generation_before_reorg = cache.snapshot_generation(); + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("different-parent replacement has no retained common ancestor"); + assert!(matches!( + error, + ReactiveEngineError::Runtime(ReactiveError::InvalidChainControl { .. }) + )); + assert_eq!(cache.snapshot_generation(), generation_before_reorg); + assert_eq!(engine.runtime().last_canonical_block(), Some(dropped)); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_reorg_accepts_an_unlogged_ancestor_inside_retained_history() { + let path = temp_path("sparse_reorg_checkpoint"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xbe); + let slot = U256::from(24); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let ancestor = BlockRef { + number: 2, + hash: B256::repeat_byte(2), + parent_hash: Some(B256::repeat_byte(1)), + timestamp: Some(1_700_000_002), + }; + let old_tip = BlockRef { + number: 3, + hash: B256::repeat_byte(3), + parent_hash: Some(ancestor.hash), + timestamp: Some(1_700_000_003), + }; + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ + batch(address, 1, b"delivery-1"), + batch(address, 3, b"delivery-3"), + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip: BlockRef { + number: 3, + hash: B256::repeat_byte(0xf3), + parent_hash: Some(ancestor.hash), + timestamp: Some(1_700_000_003), + }, + }]) + .with_delivery_token(SubscriberDeliveryToken::new( + b"reorg-through-empty-2".to_vec(), + )), + ]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig { + journal_depth: 2, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + + for _ in 0..3 { + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("in-window sparse reorg") + .expect("batch"); + } + + assert_eq!(engine.runtime().last_canonical_block(), Some(ancestor)); + assert_eq!(engine.runtime().metrics().deep_reorgs, 0); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 3); + assert_eq!( + store + .load() + .expect("load") + .expect("checkpoint") + .metadata() + .block + .number, + ancestor.number + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpointed_owner_catchup_requires_a_matching_rollback_journal_entry() { + let path = temp_path("owner_catchup_journal_guard"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xd2); + let slot = U256::from(23); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 50, b"owner-page-50") + .with_delivery_scope(evm_fork_cache::reactive::DeliveryScope::OwnerCatchup)]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let calls = Arc::new(AtomicUsize::new(0)); + let mut engine = engine(subscriber, Arc::clone(&calls), address, slot); + let mut cache = setup_cache().await.expect("cache"); + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("unrollbackable owner history must fail before mutation"); + + assert!(matches!( + error, + ReactiveEngineError::OwnerCatchupOutsideJournal { number: 50, .. } + )); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 0); + assert_eq!(cache.cached_storage_value(address, slot), None); + assert!(!store.path().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn finality_and_rollback_journal_survive_checkpoint_restore() { + let path = temp_path("runtime_state"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xb4); + let slot = U256::from(10); + let safe = BlockRef { + number: 120, + hash: B256::repeat_byte(120), + parent_hash: Some(B256::repeat_byte(119)), + timestamp: Some(1_700_000_120), + }; + let finalized = BlockRef { + number: 119, + hash: B256::repeat_byte(119), + parent_hash: Some(B256::repeat_byte(118)), + timestamp: Some(1_700_000_119), + }; + let subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 120, b"delivery-120") + .with_chain_controls([ChainControl::Safe(safe), ChainControl::Finalized(finalized)])]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path.clone(), + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut first = engine(subscriber, Arc::new(AtomicUsize::new(0)), address, slot); + let tracked = Address::repeat_byte(0xb5); + first.runtime_mut().enable_freshness_stamping(); + first + .runtime_mut() + .freshness_mut() + .expect("freshness enabled") + .valid_through_slot(address, slot + U256::from(1), 777); + first.runtime_mut().track_account( + tracked, + TrackingPolicy::Slots { + slots: vec![U256::from(91)], + }, + ); + first + .runtime_mut() + .set_root_gate_cadence(RootGateCadence::every_n_blocks(7)); + let mut cache = setup_cache().await.expect("cache"); + first + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("checkpoint") + .expect("batch"); + let loaded = store.load().expect("load").expect("checkpoint"); + let restored_metadata = loaded.metadata().clone(); + let mut restored_cache = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored_cache, &identity()) + .expect("restore"); + let empty_subscriber = OrderingSubscriber { + batches: VecDeque::new(), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut restored = engine( + empty_subscriber, + Arc::new(AtomicUsize::new(0)), + address, + slot, + ); + restored + .resume_from_durable_checkpoint(&restored_metadata) + .expect("resume runtime state"); + assert!( + restored + .runtime() + .has_journaled_handler_effects(&HandlerId::new("writer")), + "restored rollback journal must retain handler-generation ownership" + ); + assert_eq!( + restored.runtime().journaled_handler_ids(), + std::collections::HashSet::from([HandlerId::new("writer")]) + ); + assert_eq!(restored.runtime().safe_head(), Some(&safe)); + assert_eq!(restored.runtime().finalized_head(), Some(&finalized)); + assert_eq!( + restored + .runtime() + .freshness() + .expect("freshness survives restart") + .validity(address, slot + U256::from(1)), + evm_fork_cache::freshness::Validity::ValidThrough(777) + ); + assert_eq!( + restored.runtime().root_gate_cadence(), + RootGateCadence::every_n_blocks(7) + ); + assert!( + restored.runtime_mut().untrack_account(tracked), + "root-gate tracking policy survives restart" + ); + + restored + .runtime_mut() + .ingest_batch( + &mut restored_cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: finalized, + old_tip: safe, + new_tip: BlockRef { + number: 120, + hash: B256::repeat_byte(0xd0), + parent_hash: Some(finalized.hash), + timestamp: Some(1_700_000_120), + }, + }]), + ) + .expect("rollback restored journal"); + assert_ne!( + restored_cache.cached_storage_value(address, slot), + Some(U256::from(123)), + "restored journal must undo checkpointed block effects" + ); + assert!(restored.runtime().safe_head().is_none()); + assert_eq!(restored.runtime().finalized_head(), Some(&finalized)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn root_gate_baseline_and_cadence_survive_checkpoint_restore() { + let path = temp_path("root_gate_state"); + let store = DurableCheckpointStore::new(&path); + let event_address = Address::repeat_byte(0xb6); + let tracked = Address::repeat_byte(0xb7); + let slot = U256::from(15); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(event_address, 120, b"delivery-120")]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path.clone(), + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut first = engine( + subscriber, + Arc::new(AtomicUsize::new(0)), + event_address, + slot, + ); + first + .runtime_mut() + .set_root_gate_cadence(RootGateCadence::every_n_blocks(4)); + first + .runtime_mut() + .track_account(tracked, TrackingPolicy::WholeAccount); + let mut cache = setup_cache().await.expect("cache"); + install_fixed_root(&mut cache, B256::repeat_byte(0xa1)); + first + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("checkpoint baseline") + .expect("batch"); + + let loaded = store.load().expect("load").expect("checkpoint"); + let restored_metadata = loaded.metadata().clone(); + let mut restored_cache = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored_cache, &identity()) + .expect("restore cache"); + install_fixed_root(&mut restored_cache, B256::repeat_byte(0xb2)); + let empty_subscriber = OrderingSubscriber { + batches: VecDeque::new(), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut restored = engine( + empty_subscriber, + Arc::new(AtomicUsize::new(0)), + event_address, + slot, + ); + restored + .resume_from_durable_checkpoint(&restored_metadata) + .expect("resume root gate state"); + + let report = restored + .ingest_batch(&mut restored_cache, direct_batch(event_address, 124)) + .expect("next root-gate cadence boundary"); + assert!(report.reports.iter().any(|report| matches!( + report.as_ref(), + ReactiveReport::CoverageGap(gap) if gap.address == tracked && gap.block == 124 + ))); + assert!( + report + .resyncs + .iter() + .any(|request| request.reason == evm_fork_cache::reactive::ResyncReason::RootMoved) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_failure_retries_before_polling_or_reingesting() { + let root = temp_path("retry").parent().expect("parent").to_path_buf(); + let blocked_parent = root.join("blocked"); + fs::write(&blocked_parent, b"not a directory").expect("create blocking file"); + let checkpoint_path = blocked_parent.join("checkpoint.bin"); + let store = DurableCheckpointStore::new(&checkpoint_path); + let calls = Arc::new(AtomicUsize::new(0)); + let polls = Arc::new(AtomicUsize::new(0)); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let ack_saw_checkpoint = Arc::new(AtomicBool::new(false)); + let address = Address::repeat_byte(0xb1); + let slot = U256::from(5); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 50, b"delivery-50")]), + polls: Arc::clone(&polls), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: checkpoint_path.clone(), + ack_saw_checkpoint: Arc::clone(&ack_saw_checkpoint), + }; + let mut engine = engine(subscriber, Arc::clone(&calls), address, slot); + let mut cache = setup_cache().await.expect("cache"); + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("blocked checkpoint path must fail"); + assert!(matches!(error, ReactiveEngineError::Checkpoint(_))); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 0); + + fs::remove_file(&blocked_parent).expect("remove blocking file"); + fs::create_dir(&blocked_parent).expect("create checkpoint directory"); + let outcome = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect("retry commit") + .expect("pending commit outcome"); + assert!(matches!(outcome, CheckpointedIngest::Applied(_))); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "handler must not run twice" + ); + assert_eq!( + polls.load(Ordering::SeqCst), + 1, + "retry must not poll a new batch" + ); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 1); + assert!(ack_saw_checkpoint.load(Ordering::SeqCst)); + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(123)) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn acknowledgement_retry_repersists_to_a_changed_checkpoint_store() { + let first_path = temp_path("ack_store_a"); + let second_path = temp_path("ack_store_b"); + let first_store = DurableCheckpointStore::new(&first_path); + let second_store = DurableCheckpointStore::new(&second_path); + let address = Address::repeat_byte(0xb5); + let slot = U256::from(14); + struct RetrySubscriber { + batch: Option>, + fail_acknowledgement: bool, + } + impl EventSubscriber for RetrySubscriber { + fn capabilities(&self) -> SubscriberCapabilities { + durable_capabilities() + } + + fn register_interests( + &mut self, + _interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { Ok(()) }) + } + + fn next_batch(&mut self) -> SubscriberNextBatch<'_, Ethereum> { + Box::pin(async move { Ok(self.batch.take()) }) + } + + fn acknowledge_delivery( + &mut self, + _token: SubscriberDeliveryToken, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if self.fail_acknowledgement { + Err(evm_fork_cache::reactive::SubscriberError::InvalidConfig( + "forced acknowledgement failure", + )) + } else { + Ok(()) + } + }) + } + } + let subscriber = RetrySubscriber { + batch: Some(batch(address, 130, b"delivery-130")), + fail_acknowledgement: true, + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + assert!(matches!( + engine + .next_ingest_checkpointed(&mut cache, &first_store, &identity()) + .await + .expect_err("first acknowledgement fails"), + ReactiveEngineError::Acknowledgement(_) + )); + assert!(first_path.is_file()); + + engine.subscriber_mut().fail_acknowledgement = false; + assert!(matches!( + engine + .next_ingest_checkpointed(&mut cache, &second_store, &identity()) + .await + .expect("retry") + .expect("pending outcome"), + CheckpointedIngest::Applied(_) + )); + assert!(second_path.is_file()); + assert_eq!( + second_store + .load() + .expect("load second") + .expect("second checkpoint") + .metadata() + .block + .number, + 130 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cache_mutation_after_save_before_ack_fails_closed_without_rebinding_checkpoint() { + let path = temp_path("ack_retry_cache_mutation"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xba); + let event_slot = U256::from(14); + let unrelated_slot = U256::from(15); + let subscriber = ToggleAckSubscriber { + batch: Some(batch(address, 130, b"delivery-130")), + fail_acknowledgement: true, + acknowledgements: Arc::new(AtomicUsize::new(0)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot: event_slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + + assert!(matches!( + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("first acknowledgement fails after save"), + ReactiveEngineError::Acknowledgement(_) + )); + let staged_generation = cache.snapshot_generation(); + let _ = cache.apply_update(&StateUpdate::slot(address, unrelated_slot, U256::from(999))); + assert_ne!(cache.snapshot_generation(), staged_generation); + engine.subscriber_mut().fail_acknowledgement = false; + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("newer cache state cannot be attached to the staged delivery"); + assert!(matches!( + error, + ReactiveEngineError::PendingCheckpointCacheChanged { .. } + )); + assert_eq!( + engine.subscriber().acknowledgements.load(Ordering::SeqCst), + 0 + ); + + let loaded = store + .load() + .expect("load original durable state") + .expect("checkpoint"); + let mut restored = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored, &identity()) + .expect("restore original checkpoint"); + assert_eq!( + restored.cached_storage_value(address, event_slot), + Some(U256::from(123)) + ); + assert_ne!( + restored.cached_storage_value(address, unrelated_slot), + Some(U256::from(999)), + "failed retry must not re-save unrelated state under delivery-130" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn block_context_mutation_after_save_before_ack_fails_closed() { + let path = temp_path("ack_retry_block_context_mutation"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xbb); + let slot = U256::from(16); + let subscriber = ToggleAckSubscriber { + batch: Some(batch(address, 131, b"delivery-131")), + fail_acknowledgement: true, + acknowledgements: Arc::new(AtomicUsize::new(0)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + + assert!(matches!( + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("first acknowledgement fails after save"), + ReactiveEngineError::Acknowledgement(_) + )); + let staged_generation = cache.snapshot_generation(); + cache.set_timestamp(Some(1_900_000_000)); + assert_ne!(cache.snapshot_generation(), staged_generation); + engine.subscriber_mut().fail_acknowledgement = false; + + assert!(matches!( + engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("changed persisted block context must stop acknowledgement"), + ReactiveEngineError::PendingCheckpointCacheChanged { .. } + )); + assert_eq!( + engine.subscriber().acknowledgements.load(Ordering::SeqCst), + 0 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn restored_delivery_token_suppresses_cross_process_replay() { + let path = temp_path("replay"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xc1); + let slot = U256::from(9); + let first_calls = Arc::new(AtomicUsize::new(0)); + let first_subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 60, b"delivery-60")]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path.clone(), + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut first_engine = engine(first_subscriber, Arc::clone(&first_calls), address, slot); + let mut first_cache = setup_cache().await.expect("first cache"); + first_engine + .next_ingest_checkpointed(&mut first_cache, &store, &identity()) + .await + .expect("first commit") + .expect("first outcome"); + assert_eq!(first_calls.load(Ordering::SeqCst), 1); + + let loaded = store.load().expect("load checkpoint").expect("checkpoint"); + let restored_metadata = loaded.metadata().clone(); + let mut restored_cache = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored_cache, &identity()) + .expect("restore cache"); + + let replay_calls = Arc::new(AtomicUsize::new(0)); + let replay_acks = Arc::new(AtomicUsize::new(0)); + let replay_subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 60, b"delivery-60")]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&replay_acks), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut replay_engine = engine(replay_subscriber, Arc::clone(&replay_calls), address, slot); + replay_engine + .resume_from_durable_checkpoint(&restored_metadata) + .expect("resume delivery bookkeeping"); + let outcome = replay_engine + .next_ingest_checkpointed(&mut restored_cache, &store, &identity()) + .await + .expect("acknowledge replay") + .expect("replay outcome"); + assert!(matches!(outcome, CheckpointedIngest::ReplayAcknowledged)); + assert_eq!(replay_calls.load(Ordering::SeqCst), 0); + assert_eq!(replay_acks.load(Ordering::SeqCst), 1); + assert_eq!( + restored_cache.cached_storage_value(address, slot), + Some(U256::from(123)) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn restored_token_rejects_a_different_delivery_before_ack_or_ingest() { + let path = temp_path("replay_witness_mismatch"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xc8); + let slot = U256::from(18); + let first_subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 60, b"delivery-60") + .with_subscriber_checkpoint(SubscriberCheckpoint::new(b"cursor-original".to_vec()))]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path.clone(), + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut first = engine( + first_subscriber, + Arc::new(AtomicUsize::new(0)), + address, + slot, + ); + let mut first_cache = setup_cache().await.expect("first cache"); + first + .next_ingest_checkpointed(&mut first_cache, &store, &identity()) + .await + .expect("first commit") + .expect("first outcome"); + + let loaded = store.load().expect("load").expect("checkpoint"); + let metadata = loaded.metadata().clone(); + assert!(metadata.delivery_witness.is_some()); + let mut restored_cache = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored_cache, &identity()) + .expect("restore cache"); + + let replay_calls = Arc::new(AtomicUsize::new(0)); + let replay_acks = Arc::new(AtomicUsize::new(0)); + let replay_subscriber = OrderingSubscriber { + // Reusing the token with a different provider cursor is a different + // delivery even though its EVM log happens to be identical. + batches: VecDeque::from([batch(address, 60, b"delivery-60") + .with_subscriber_checkpoint(SubscriberCheckpoint::new(b"cursor-reused".to_vec()))]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&replay_acks), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut replay = engine(replay_subscriber, Arc::clone(&replay_calls), address, slot); + replay + .resume_from_durable_checkpoint(&metadata) + .expect("resume"); + + let error = replay + .next_ingest_checkpointed(&mut restored_cache, &store, &identity()) + .await + .expect_err("token reuse must not bypass delivery comparison"); + assert!(matches!(error, ReactiveEngineError::ReplayDeliveryMismatch)); + assert_eq!(replay_calls.load(Ordering::SeqCst), 0); + assert_eq!(replay_acks.load(Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tokened_header_requires_an_exact_source_payload_commitment() { + let path = temp_path("header_commitment_required"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xca); + let slot = U256::from(24); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([header_batch(30_000_000, b"header-70", None)]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&acknowledgements), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut engine = engine(subscriber, Arc::new(AtomicUsize::new(0)), address, slot); + let mut cache = setup_cache().await.expect("cache"); + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("generic header payload cannot be durably witnessed by hash alone"); + assert!(matches!( + error, + ReactiveEngineError::MissingPayloadCommitment + )); + assert_eq!(acknowledgements.load(Ordering::SeqCst), 0); + assert!(engine.runtime().last_canonical_block().is_none()); + assert!(!store.path().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn header_payload_commitment_accepts_exact_replay_and_rejects_body_or_digest_changes() { + let path = temp_path("header_commitment_replay"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xcb); + let slot = U256::from(25); + let original_commitment = SubscriberPayloadCommitment::new(keccak256(b"header-wire-a")); + let first_subscriber = OrderingSubscriber { + batches: VecDeque::from([header_batch( + 30_000_000, + b"header-70", + Some(original_commitment), + )]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path.clone(), + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut first = engine( + first_subscriber, + Arc::new(AtomicUsize::new(0)), + address, + slot, + ); + let mut first_cache = setup_cache().await.expect("first cache"); + first + .next_ingest_checkpointed(&mut first_cache, &store, &identity()) + .await + .expect("commit witnessed header") + .expect("header batch"); + let loaded = store.load().expect("load").expect("checkpoint"); + let metadata = loaded.metadata().clone(); + + let exact_acks = Arc::new(AtomicUsize::new(0)); + let exact_subscriber = OrderingSubscriber { + batches: VecDeque::from([header_batch( + 30_000_000, + b"header-70", + Some(original_commitment), + )]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&exact_acks), + checkpoint_path: path.clone(), + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut exact = engine( + exact_subscriber, + Arc::new(AtomicUsize::new(0)), + address, + slot, + ); + exact + .resume_from_durable_checkpoint(&metadata) + .expect("resume exact replay"); + let mut exact_cache = setup_cache().await.expect("exact replay cache"); + loaded + .restore_into(&mut exact_cache, &identity()) + .expect("restore exact cache"); + assert!(matches!( + exact + .next_ingest_checkpointed(&mut exact_cache, &store, &identity()) + .await + .expect("exact replay") + .expect("outcome"), + CheckpointedIngest::ReplayAcknowledged + )); + assert_eq!(exact_acks.load(Ordering::SeqCst), 1); + + let changed_commitment = SubscriberPayloadCommitment::new(keccak256(b"header-wire-b")); + for (gas_limit, commitment) in [ + (31_000_000, changed_commitment), + (30_000_000, changed_commitment), + ] { + let replay_acks = Arc::new(AtomicUsize::new(0)); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([header_batch(gas_limit, b"header-70", Some(commitment))]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&replay_acks), + checkpoint_path: path.clone(), + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut replay = engine(subscriber, Arc::new(AtomicUsize::new(0)), address, slot); + replay + .resume_from_durable_checkpoint(&metadata) + .expect("resume mismatch replay"); + let reloaded = store.load().expect("reload").expect("checkpoint"); + let mut cache = setup_cache().await.expect("mismatch cache"); + reloaded + .restore_into(&mut cache, &identity()) + .expect("restore mismatch cache"); + + let error = replay + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("body or commitment change must not reuse the token"); + assert!(matches!(error, ReactiveEngineError::ReplayDeliveryMismatch)); + assert_eq!(replay_acks.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn manually_stored_token_without_a_witness_cannot_skip_replay() { + let address = Address::repeat_byte(0xc9); + let slot = U256::from(20); + let checkpoint = metadata(62, b"delivery-62"); + assert!(checkpoint.delivery_witness.is_none()); + let replay_acks = Arc::new(AtomicUsize::new(0)); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([batch(address, 62, b"delivery-62")]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::clone(&replay_acks), + checkpoint_path: temp_path("missing_replay_witness"), + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut replay = engine(subscriber, Arc::new(AtomicUsize::new(0)), address, slot); + replay + .resume_from_durable_checkpoint(&checkpoint) + .expect("resume low-level metadata"); + let mut cache = setup_cache().await.expect("cache"); + let store = DurableCheckpointStore::new(temp_path("missing_replay_witness_store")); + + let error = replay + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("unwitnessed token cannot use replay shortcut"); + assert!(matches!(error, ReactiveEngineError::MissingReplayWitness)); + assert_eq!(replay_acks.load(Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn crash_after_durable_save_before_ack_replays_only_the_acknowledgement() { + let path = temp_path("saved_before_ack_crash"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xc2); + let slot = U256::from(19); + let first_calls = Arc::new(AtomicUsize::new(0)); + let first_subscriber = ToggleAckSubscriber { + batch: Some(batch(address, 61, b"delivery-61")), + fail_acknowledgement: true, + acknowledgements: Arc::new(AtomicUsize::new(0)), + }; + let mut first_runtime = ReactiveRuntime::new(ReactiveConfig::default()); + first_runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::clone(&first_calls), + address, + slot, + })) + .expect("register first writer"); + let mut first_engine = ReactiveEngine::new(first_runtime, first_subscriber); + let mut first_cache = setup_cache().await.expect("first cache"); + + assert!(matches!( + first_engine + .next_ingest_checkpointed(&mut first_cache, &store, &identity()) + .await + .expect_err("source acknowledgement fails after durable save"), + ReactiveEngineError::Acknowledgement(_) + )); + assert!( + path.is_file(), + "checkpoint committed before acknowledgement" + ); + assert_eq!(first_calls.load(Ordering::SeqCst), 1); + drop(first_engine); + drop(first_cache); + + let loaded = store.load().expect("load checkpoint").expect("checkpoint"); + let restored_metadata = loaded.metadata().clone(); + assert_eq!( + restored_metadata.delivery_token.as_deref(), + Some(&b"delivery-61"[..]) + ); + let mut restored_cache = setup_cache().await.expect("restored cache"); + loaded + .restore_into(&mut restored_cache, &identity()) + .expect("restore cache"); + let replay_calls = Arc::new(AtomicUsize::new(0)); + let replay_acks = Arc::new(AtomicUsize::new(0)); + let replay_subscriber = ToggleAckSubscriber { + batch: Some(batch(address, 61, b"delivery-61")), + fail_acknowledgement: false, + acknowledgements: Arc::clone(&replay_acks), + }; + let mut replay_runtime = ReactiveRuntime::new(ReactiveConfig::default()); + replay_runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::clone(&replay_calls), + address, + slot, + })) + .expect("register replay writer"); + let mut replay_engine = ReactiveEngine::new(replay_runtime, replay_subscriber); + replay_engine + .resume_from_durable_checkpoint(&restored_metadata) + .expect("resume committed token"); + + assert!(matches!( + replay_engine + .next_ingest_checkpointed(&mut restored_cache, &store, &identity()) + .await + .expect("replay acknowledgement") + .expect("replay outcome"), + CheckpointedIngest::ReplayAcknowledged + )); + assert_eq!(replay_calls.load(Ordering::SeqCst), 0); + assert_eq!(replay_acks.load(Ordering::SeqCst), 1); + assert_eq!( + restored_cache.cached_storage_value(address, slot), + Some(U256::from(123)) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn into_parts_retains_an_engine_with_uncommitted_delivery_state() { + let address = Address::repeat_byte(0xca); + let slot = U256::from(27); + let acknowledgements = Arc::new(AtomicUsize::new(0)); + let subscriber = ToggleAckSubscriber { + batch: Some(batch(address, 63, b"delivery-63")), + fail_acknowledgement: true, + acknowledgements: Arc::clone(&acknowledgements), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(CountingWriter { + calls: Arc::new(AtomicUsize::new(0)), + address, + slot, + })) + .expect("register writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + + assert!(matches!( + engine + .next_ingest(&mut cache) + .await + .expect_err("first acknowledgement is forced to fail"), + ReactiveEngineError::Acknowledgement(_) + )); + let mut engine = match engine.into_parts() { + Err(engine) => *engine, + Ok(_) => panic!("pending delivery state must prevent destructive extraction"), + }; + engine.subscriber_mut().fail_acknowledgement = false; + engine + .next_ingest(&mut cache) + .await + .expect("retry commits the retained acknowledgement") + .expect("retained report"); + + assert_eq!(acknowledgements.load(Ordering::SeqCst), 1); + assert!(engine.into_parts().is_ok()); +} + +struct FailOnBlockWriter { + address: Address, + slot: U256, + fail_on: u64, +} + +impl ReactiveHandler for FailOnBlockWriter { + fn id(&self) -> HandlerId { + HandlerId::new("fail-on-second-record") + } + + fn interests(&self) -> Vec> { + vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(self.address), + local_matcher: None, + route_key: None, + })] + } + + fn handle( + &self, + ctx: &ReactiveContext, + _input: &ReactiveInput, + _state: &dyn StateView, + ) -> Result { + if ctx + .block + .as_ref() + .is_some_and(|block| block.number == self.fail_on) + { + return Err(HandlerError::new("forced second-record failure")); + } + Ok(HandlerOutcome { + effects: vec![ReactiveEffect::StateUpdate(StateUpdate::slot_delta( + self.address, + self.slot, + SlotDelta::Add(U256::from(1)), + ))], + quality: StateEffectQuality::ExactFromInput, + tags: Vec::new(), + }) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn failed_multi_record_ingest_rolls_back_cache_and_runtime_before_replay() { + let path = temp_path("ingest_rollback"); + let store = DurableCheckpointStore::new(&path); + let address = Address::repeat_byte(0xd1); + let slot = U256::from(12); + let mut records = batch(address, 70, b"unused").into_records(); + records.extend(batch(address, 71, b"unused").into_records()); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([ReactiveInputBatch::new(records) + .with_delivery_token(SubscriberDeliveryToken::new(b"delivery-71".to_vec()))]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(FailOnBlockWriter { + address, + slot, + fail_on: 71, + })) + .expect("register failing writer"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + let _ = cache.apply_update(&StateUpdate::slot(address, slot, U256::from(10))); + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("second record fails"); + assert!(matches!(error, ReactiveEngineError::Runtime(_))); + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(10)) + ); + assert!(engine.runtime().last_canonical_block().is_none()); + assert_eq!( + engine.subscriber().acknowledgements.load(Ordering::SeqCst), + 0 + ); + assert!(!store.path().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_stage_failure_rolls_back_before_dispatching_hooks() { + let path = temp_path("stage_before_hooks"); + let store = DurableCheckpointStore::new(&path); + let hooks = Arc::new(AtomicUsize::new(0)); + let pending = ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::PendingTxHash(B256::repeat_byte(0xef)), + ReactiveContext { + chain_id: Some(1), + source: InputSource::Subscription, + chain_status: ChainStatus::Pending, + block: None, + transaction_index: None, + log_index: None, + }, + )]) + .with_delivery_token(SubscriberDeliveryToken::new(b"pending-only".to_vec())); + let subscriber = OrderingSubscriber { + batches: VecDeque::from([pending]), + polls: Arc::new(AtomicUsize::new(0)), + acknowledgements: Arc::new(AtomicUsize::new(0)), + checkpoint_path: path, + ack_saw_checkpoint: Arc::new(AtomicBool::new(false)), + }; + let mut runtime = ReactiveRuntime::new(ReactiveConfig::default()); + runtime + .register_hook(Arc::new(CountingHook(Arc::clone(&hooks)))) + .expect("register hook"); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity()) + .await + .expect_err("pending-only state has no canonical checkpoint anchor"); + + assert!(matches!(error, ReactiveEngineError::MissingCheckpointBlock)); + assert_eq!( + hooks.load(Ordering::SeqCst), + 0, + "rolled-back reports must not escape through hooks" + ); + assert!(engine.runtime().last_canonical_block().is_none()); + assert!(!store.path().exists()); +} diff --git a/tests/event_pipeline.rs b/tests/event_pipeline.rs index e4dfb2c..a250312 100644 --- a/tests/event_pipeline.rs +++ b/tests/event_pipeline.rs @@ -608,7 +608,7 @@ async fn ingest_keeps_state_fresh_with_zero_fetches() -> Result<()> { Ok(()) } -/// WS-3 (manager-authored red-green): `derived_slots` must be bounded to the +/// WS-3 red-green coverage: `derived_slots` must be bounded to the /// reorg horizon (`ReorgConfig::depth`), mirroring the `touched` ring, rather /// than growing unbounded across steady-state ingestion. With `depth = 3`, /// after ingesting 6 blocks that each touch a distinct `(address, slot)`, only diff --git a/tests/freshness.rs b/tests/freshness.rs index 54c8d9a..6b91485 100644 --- a/tests/freshness.rs +++ b/tests/freshness.rs @@ -2031,7 +2031,7 @@ async fn run_unverified_when_fixed_point_round_cap_exceeded() -> Result<()> { Ok(()) } -/// WS-1c (manager-authored red-green): the verdict taxonomy distinguishes a +/// WS-1c red-green coverage: the verdict taxonomy distinguishes a /// storage-only confirmation from a full (storage + account) one, so callers can /// no longer mistake "no volatile storage slot changed" for "account state /// verified". The storage-only success verdict is renamed `Confirmed -> diff --git a/tests/liveness_cold_start.rs b/tests/liveness_cold_start.rs index 68a0a1c..97f8551 100644 --- a/tests/liveness_cold_start.rs +++ b/tests/liveness_cold_start.rs @@ -1,4 +1,4 @@ -//! Manager-authored red-green acceptance tests for Phase-8 step 5: the +//! Red-green acceptance tests for Phase-8 step 5: the //! cold-start root baseline (`roots.bin`). //! //! A process restarting after downtime should not blindly re-read its whole @@ -217,7 +217,7 @@ async fn missing_baseline_entry_rereads_and_adopts() -> Result<()> { } // --------------------------------------------------------------------------- -// Implementation-agent tests (Wave 8): guard, probe-failure no-clobber, mixed run +// Wave 8 coverage: guard, probe-failure no-clobber, mixed run // --------------------------------------------------------------------------- /// Phase-8 s5: a probe_roots-bearing round over a cache with no account-proof diff --git a/tests/liveness_root_gate.rs b/tests/liveness_root_gate.rs index b77facb..5ac5ff7 100644 --- a/tests/liveness_root_gate.rs +++ b/tests/liveness_root_gate.rs @@ -1,4 +1,4 @@ -//! Manager-authored red-green acceptance tests for Phase-8 step 4: the +//! Red-green acceptance tests for Phase-8 step 4: the //! `storageHash` root gate. //! //! A `WholeAccount`-tracked contract's storage root is a sound per-account change @@ -16,24 +16,28 @@ mod common; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; -use alloy_consensus::Header; -use alloy_network::Ethereum; +use alloy_consensus::{BlockHeader as _, Header}; +use alloy_network::{Ethereum, primitives::HeaderResponse as _}; use alloy_primitives::{Address, B256, U256}; use anyhow::Result; use common::setup_cache; use evm_fork_cache::cache::AccountProof; use evm_fork_cache::reactive::{ - ChainStatus, InputSource, ReactiveConfig, ReactiveContext, ReactiveInput, ReactiveInputBatch, - ReactiveInputRecord, ReactiveReport, ReactiveRuntime, ResyncReason, RootGateCadence, - TrackingPolicy, + BlockRef, ChainControl, ChainStatus, InputSource, ReactiveConfig, ReactiveContext, + ReactiveInput, ReactiveInputBatch, ReactiveInputRecord, ReactiveReport, ReactiveRuntime, + ResyncReason, RootGateCadence, TrackingPolicy, }; /// A canonical block-header input for block `number`. -fn header_input(number: u64) -> ReactiveInput { - let consensus = Header { +fn canonical_header(number: u64) -> Header { + Header { + parent_hash: number + .checked_sub(1) + .map(|parent| canonical_header(parent).hash_slow()) + .unwrap_or_default(), number, timestamp: 1_700_000_000 + number, base_fee_per_gas: Some(7), @@ -41,8 +45,11 @@ fn header_input(number: u64) -> ReactiveInput { gas_limit: 30_000_000, mix_hash: B256::repeat_byte(0xab), ..Default::default() - }; - ReactiveInput::BlockHeader(alloy_rpc_types_eth::Header::new(consensus)) + } +} + +fn header_input(number: u64) -> ReactiveInput { + ReactiveInput::BlockHeader(alloy_rpc_types_eth::Header::new(canonical_header(number))) } fn canonical_context(number: u64) -> ReactiveContext { @@ -56,7 +63,7 @@ fn canonical_context(number: u64) -> ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -66,12 +73,90 @@ fn canonical_context(number: u64) -> ReactiveContext { } fn header_batch(number: u64) -> ReactiveInputBatch { + let input = header_input(number); + let ReactiveInput::BlockHeader(header) = &input else { + unreachable!("header helper always returns a header") + }; + let block = evm_fork_cache::reactive::BlockRef { + number: header.number(), + hash: header.hash(), + parent_hash: Some(header.parent_hash()), + timestamp: Some(header.timestamp()), + }; ReactiveInputBatch::new(vec![ReactiveInputRecord::new( - header_input(number), - canonical_context(number), + input, + ReactiveContext { + chain_id: Some(1), + source: InputSource::Batch, + chain_status: ChainStatus::Included { + block, + confirmations: 0, + }, + block: Some(block), + transaction_index: None, + log_index: None, + }, + )]) +} + +fn inert_canonical_batch(block: BlockRef) -> ReactiveInputBatch { + let log = Log { + inner: PrimitiveLog::new_unchecked( + Address::repeat_byte(0xfe), + vec![B256::repeat_byte(0xef)], + Bytes::new(), + ), + block_hash: Some(block.hash), + block_number: Some(block.number), + block_timestamp: block.timestamp, + transaction_hash: Some(B256::repeat_byte(block.number as u8)), + transaction_index: Some(0), + log_index: Some(0), + removed: false, + }; + ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(log), + ReactiveContext { + chain_id: Some(1), + source: InputSource::Batch, + chain_status: ChainStatus::Included { + block, + confirmations: 0, + }, + block: Some(block), + transaction_index: Some(0), + log_index: Some(0), + }, )]) } +fn install_mutable_root_fetcher( + cache: &mut evm_fork_cache::cache::EvmCache, + initial_root: B256, +) -> Arc> { + let root = Arc::new(Mutex::new(initial_root)); + let fetch_root = root.clone(); + cache.set_account_proof_fetcher(Arc::new(move |requests, _block| { + let current = *fetch_root.lock().expect("root lock"); + requests + .into_iter() + .map(|(address, _keys)| { + ( + address, + Ok(AccountProof { + storage_hash: current, + balance: U256::ZERO, + nonce: 0, + code_hash: B256::ZERO, + slots: Vec::new(), + }), + ) + }) + .collect() + })); + root +} + /// Install an account-proof fetcher whose `storage_hash` for any address is a /// function of the probed block: blocks `<= pivot` return `root_a`, blocks /// `> pivot` return `root_b`. Deterministic regardless of probe cadence. @@ -206,8 +291,153 @@ async fn whole_account_root_unchanged_no_gap_or_resync() -> Result<()> { Ok(()) } +#[tokio::test] +async fn control_only_canonical_progress_drives_root_gate_cadence_and_detection() -> Result<()> { + let tracked = Address::repeat_byte(0x7a); + let mut cache = setup_cache().await?; + install_block_keyed_root_fetcher( + &mut cache, + 10, + B256::repeat_byte(0xa1), + B256::repeat_byte(0xb2), + ); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.set_root_gate_cadence(RootGateCadence::every_n_blocks(1)); + runtime.track_account(tracked, TrackingPolicy::WholeAccount); + + let block_10 = BlockRef { + number: 10, + hash: B256::repeat_byte(10), + parent_hash: Some(B256::repeat_byte(9)), + timestamp: Some(1_700_000_010), + }; + let baseline = runtime.ingest_batch_with_resync( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::CanonicalProgress(block_10)]), + )?; + assert!(baseline.resyncs.is_empty()); + + let block_11 = BlockRef { + number: 11, + hash: B256::repeat_byte(11), + parent_hash: Some(block_10.hash), + timestamp: Some(1_700_000_011), + }; + let moved = runtime.ingest_batch_with_resync( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Barrier { + id: b"control-only-root-gate".to_vec(), + block: Some(block_11), + }]), + )?; + + assert!(moved.reports.iter().any(|report| matches!( + report.as_ref(), + ReactiveReport::CoverageGap(gap) if gap.address == tracked && gap.block == 11 + ))); + assert!( + moved + .resyncs + .iter() + .any(|request| request.reason == ResyncReason::RootMoved) + ); + assert_eq!(runtime.metrics().coverage_gaps, 1); + Ok(()) +} + +/// A root baseline observed on an orphaned branch must not survive the reorg. +/// Otherwise the replacement block at the same height is ignored as stale and +/// the first child of that replacement is falsely reported as an uncovered +/// root move. +#[tokio::test] +async fn reorg_discards_orphaned_root_baseline_before_replacement_branch() -> Result<()> { + let tracked = Address::repeat_byte(0x79); + let ancestor = BlockRef { + number: 10, + hash: B256::repeat_byte(0x10), + parent_hash: Some(B256::repeat_byte(0x09)), + timestamp: Some(1_700_000_010), + }; + let old_tip = BlockRef { + number: 11, + hash: B256::repeat_byte(0x11), + parent_hash: Some(ancestor.hash), + timestamp: Some(1_700_000_011), + }; + let replacement = BlockRef { + number: 11, + hash: B256::repeat_byte(0xa1), + parent_hash: Some(ancestor.hash), + timestamp: Some(1_700_000_011), + }; + let replacement_child = BlockRef { + number: 12, + hash: B256::repeat_byte(0xa2), + parent_hash: Some(replacement.hash), + timestamp: Some(1_700_000_012), + }; + + let root_a = B256::repeat_byte(0x31); + let root_b = B256::repeat_byte(0x32); + let root_c = B256::repeat_byte(0x33); + let mut cache = setup_cache().await?; + let current_root = install_mutable_root_fetcher(&mut cache, root_a); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.set_root_gate_cadence(RootGateCadence::every_n_blocks(1)); + runtime.track_account(tracked, TrackingPolicy::WholeAccount); + + runtime.ingest_batch_with_resync(&mut cache, inert_canonical_batch(ancestor))?; + *current_root.lock().expect("root lock") = root_b; + let orphan_report = + runtime.ingest_batch_with_resync(&mut cache, inert_canonical_batch(old_tip))?; + assert!(orphan_report.reports.iter().any(|report| matches!( + report.as_ref(), + ReactiveReport::CoverageGap(gap) if gap.address == tracked && gap.block == old_tip.number + ))); + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip: replacement, + }]), + )?; + + *current_root.lock().expect("root lock") = root_c; + let replacement_report = + runtime.ingest_batch_with_resync(&mut cache, inert_canonical_batch(replacement))?; + let child_report = + runtime.ingest_batch_with_resync(&mut cache, inert_canonical_batch(replacement_child))?; + + for report in [&replacement_report, &child_report] { + assert!( + !report + .reports + .iter() + .any(|item| matches!(item.as_ref(), ReactiveReport::CoverageGap(_))), + "replacement-branch baseline adoption must not create a false coverage gap" + ); + assert!( + !report + .resyncs + .iter() + .any(|request| request.reason == ResyncReason::RootMoved), + "replacement-branch baseline adoption must not schedule a false resync" + ); + } + assert_eq!(runtime.metrics().coverage_gaps, 1); + Ok(()) +} + // ---------------------------------------------------------------------------- -// Wave-7 (implementation-agent) tests: decoder-covered move, Scalars field move, +// Wave-7 tests: decoder-covered move, Scalars field move, // and the Slots opt-out. // ---------------------------------------------------------------------------- diff --git a/tests/reactive_alloy_subscriber.rs b/tests/reactive_alloy_subscriber.rs index 5b3b7d6..5da5189 100644 --- a/tests/reactive_alloy_subscriber.rs +++ b/tests/reactive_alloy_subscriber.rs @@ -1,4 +1,4 @@ -//! Manager-authored acceptance tests for the out-of-the-box Alloy subscriber. +//! Acceptance tests for the out-of-the-box Alloy subscriber. //! //! These tests pin the default WebSocket/pubsub subscriber surface and the //! opt-in HTTP polling fallback. @@ -7,7 +7,7 @@ use std::time::Duration; use alloy_network::Ethereum; -#[cfg(feature = "reactive-polling")] +#[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use alloy_primitives::U256; #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use alloy_primitives::{Address, keccak256}; @@ -18,7 +18,7 @@ use alloy_provider::ProviderBuilder; use alloy_rpc_types_eth::Filter; #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use alloy_rpc_types_eth::Log; -#[cfg(feature = "reactive-polling")] +#[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use alloy_rpc_types_eth::{Block, Header}; use alloy_transport::mock::Asserter; use anyhow::Result; @@ -27,13 +27,16 @@ use anyhow::bail; #[cfg(feature = "reactive-ws")] use evm_fork_cache::reactive::BlockInterestMode; -#[cfg(feature = "reactive-ws")] +#[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use evm_fork_cache::reactive::SubscriberBackfill; +#[cfg(feature = "reactive-ws")] +use evm_fork_cache::reactive::SubscriberCapability; #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use evm_fork_cache::reactive::SubscriberOwnerError; use evm_fork_cache::reactive::{ - AlloySubscriber, EventSubscriber, PendingTxInterest, ReactiveInterest, SubscriberConfig, - SubscriberError, SubscriberMode, SubscriberReconnectConfig, + AlloySubscriber, DeliveryAudience, DeliveryScope, EventSubscriber, InterestOwnerSubscriber, + PendingTxInterest, ReactiveInterest, SubscriberConfig, SubscriberError, SubscriberMode, + SubscriberReconnectConfig, }; #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use evm_fork_cache::reactive::{BlockInterest, LogInterest}; @@ -66,7 +69,7 @@ fn removed_rpc_log(address: Address, topic0: B256, block_number: u64, log_index: log } -#[cfg(feature = "reactive-polling")] +#[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] fn rpc_block(point: &BlockRef) -> Block { Block::empty(Header { hash: point.hash, @@ -105,10 +108,65 @@ fn polling_subscriber( ) } +#[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] +fn asserter_with_chain_id() -> Asserter { + let asserter = Asserter::new(); + asserter.push_success(&U256::from(1)); + asserter +} + +#[test] +#[cfg(feature = "reactive-ws")] +fn alloy_subscriber_advertises_only_implemented_guarantees() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ); + let capabilities = subscriber.capabilities(); + for capability in [ + SubscriberCapability::Logs, + SubscriberCapability::BlockHeaders, + SubscriberCapability::PendingTransactionHashes, + SubscriberCapability::HistoricalBackfill, + SubscriberCapability::Live, + SubscriberCapability::OwnerScopedDelivery, + SubscriberCapability::DynamicInterests, + ] { + assert!(capabilities.supports(capability), "missing {capability:?}"); + } + assert!(!capabilities.supports(SubscriberCapability::DurableReplay)); + assert!(!capabilities.supports(SubscriberCapability::ExplicitReorgs)); + assert!(!capabilities.supports(SubscriberCapability::FinalityUpdates)); + assert!(!capabilities.supports(SubscriberCapability::Barriers)); +} + +#[test] +#[cfg(not(any(feature = "reactive-ws", feature = "reactive-polling")))] +fn alloy_subscriber_does_not_advertise_uncompiled_transports() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + for mode in [ + SubscriberMode::Auto, + SubscriberMode::PubSub, + SubscriberMode::Polling, + ] { + let subscriber = AlloySubscriber::<_, Ethereum>::new( + provider.clone(), + mode, + SubscriberConfig::default(), + ); + assert!( + subscriber.capabilities().iter().next().is_none(), + "{mode:?} cannot promise behavior whose transport is absent" + ); + } +} + #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn staged_reconcile_subscribes_before_fetching_owner_backfill() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let pool = Address::repeat_byte(0xa4); let topic = keccak256(b"Swap()"); let through = BlockRef { @@ -144,35 +202,174 @@ async fn staged_reconcile_subscribes_before_fetching_owner_backfill() -> Result< "post-block owner cannot activate before certified reconcile" ); - let progress = subscriber - .reconcile_interest_owner(&epoch, through.clone()) - .await?; + let progress = subscriber.reconcile_interest_owner(&epoch, through).await?; assert_eq!(progress.owner(), &epoch); assert_eq!(progress.through(), &through); assert!(subscriber.activate_interest_owner(&epoch)); let batch = subscriber - .next_scoped_batch() + .next_batch() .await? .expect("owner-only catch-up record"); assert_eq!(batch.records().len(), 1); - assert!( - matches!(&batch.records()[0].record().input, ReactiveInput::Log(actual) if actual == &log) + assert!(matches!(&batch.records()[0].input, ReactiveInput::Log(actual) if actual == &log)); + assert_eq!( + batch.record_audience(0), + Some(&DeliveryAudience::Owners(vec![HandlerId::new("pool-a")])) ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "reactive-polling")] +async fn coordinated_registration_subscribes_then_delivers_c_owner_and_post_c_global() -> Result<()> +{ + let asserter = asserter_with_chain_id(); + let existing_pool = Address::repeat_byte(0xa1); + let new_pool = Address::repeat_byte(0xb2); + let topic = keccak256(b"Swap()"); + let retained = BlockRef { + number: 100, + hash: B256::repeat_byte(100), + parent_hash: Some(B256::repeat_byte(99)), + timestamp: Some(1_700_000_100), + }; + let activation = BlockRef { + number: 102, + hash: B256::repeat_byte(102), + parent_hash: Some(B256::repeat_byte(101)), + timestamp: Some(1_700_000_102), + }; + let owner_at_c = rpc_log(new_pool, topic, 100, 0); + let existing_after_c = rpc_log(existing_pool, topic, 101, 0); + let new_after_c = rpc_log(new_pool, topic, 102, 1); + + // Strict order proves subscribe-first adoption. The owner-only C window is + // certified first; the following poll resolves one global C+1..H window. + asserter.push_success(&U256::from(1)); // eth_newFilter + asserter.push_success(&Some(rpc_block(&retained))); + asserter.push_success(&vec![owner_at_c.clone()]); + asserter.push_success(&Some(rpc_block(&retained))); + asserter.push_success(&102u64); + asserter.push_success(&Some(rpc_block(&activation))); + asserter.push_success(&Some(rpc_block(&retained))); + asserter.push_success(&vec![existing_after_c.clone(), new_after_c.clone()]); + asserter.push_success(&Some(rpc_block(&activation))); + + let mut subscriber = polling_subscriber(asserter.clone(), 16); + subscriber.add_interest_owner( + HandlerId::new("existing"), + &[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(existing_pool).event_signature(topic), + local_matcher: None, + route_key: None, + })], + )?; + InterestOwnerSubscriber::add_interest_owner_with_canonical_catchup( + &mut subscriber, + HandlerId::new("new"), + &[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(new_pool).event_signature(topic), + local_matcher: None, + route_key: None, + })], + retained, + ) + .await?; + + let owner_batch = subscriber + .next_batch() + .await? + .expect("owner-only retained-block catch-up"); + assert_eq!(owner_batch.records().len(), 1); assert_eq!( - batch.records()[0].scope(), - &SubscriberInputScope::OwnerOnly { - owners: vec![epoch] - } + owner_batch.record_audience(0), + Some(&DeliveryAudience::Owners(vec![HandlerId::new("new")])) + ); + assert_eq!( + owner_batch.record_delivery_scope(0), + Some(DeliveryScope::OwnerCatchup) + ); + assert!( + matches!(&owner_batch.records()[0].input, ReactiveInput::Log(log) if log == &owner_at_c) + ); + + let global_batch = subscriber + .next_batch() + .await? + .expect("globally ordered post-C catch-up"); + assert_eq!(global_batch.records().len(), 2); + assert!( + matches!(&global_batch.records()[0].input, ReactiveInput::Log(log) if log == &existing_after_c) + ); + assert!( + matches!(&global_batch.records()[1].input, ReactiveInput::Log(log) if log == &new_after_c) ); + assert!((0..2).all(|index| { + global_batch.record_delivery_scope(index) == Some(DeliveryScope::CanonicalProgress) + })); + assert!(matches!( + global_batch.chain_controls(), + [evm_fork_cache::reactive::ChainControl::Barrier { + block: Some(block), + .. + }] if block == &activation + )); + assert!(asserter.read_q().is_empty(), "unexpected coordinated RPCs"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "reactive-polling")] +async fn exact_global_zero_log_window_emits_certified_progress_barrier() -> Result<()> { + let asserter = asserter_with_chain_id(); + let pool = Address::repeat_byte(0xc3); + let baseline = BlockRef { + number: 100, + hash: B256::repeat_byte(100), + parent_hash: Some(B256::repeat_byte(99)), + timestamp: Some(1_700_000_100), + }; + asserter.push_success(&U256::from(1)); // eth_newFilter before history + asserter.push_success(&100u64); // open range resolves to empty C+1..C + asserter.push_success(&Some(rpc_block(&baseline))); + let mut subscriber = polling_subscriber(asserter.clone(), 16); + InterestOwnerSubscriber::replace_interest_owners_with_global_backfill( + &mut subscriber, + vec![( + HandlerId::new("pool"), + vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(pool), + local_matcher: None, + route_key: None, + })], + )], + evm_fork_cache::reactive::SubscriberBackfill::after_canonical_block(baseline)?, + ) + .await?; + let batch = subscriber + .next_batch() + .await? + .expect("control-only certified progress"); + assert!(batch.records().is_empty()); + assert_eq!(batch.chain_id(), Some(1)); + assert!(matches!( + batch.chain_controls(), + [evm_fork_cache::reactive::ChainControl::Barrier { + block: Some(block), + .. + }] if block == &baseline + )); + assert!(asserter.read_q().is_empty(), "unexpected zero-log RPCs"); Ok(()) } #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn bulk_reconcile_routes_merged_backfill_to_exact_owner_epochs() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let pool_a = Address::repeat_byte(0xb1); let pool_b = Address::repeat_byte(0xb2); let topic = keccak256(b"Swap()"); @@ -205,7 +402,7 @@ async fn bulk_reconcile_routes_merged_backfill_to_exact_owner_epochs() -> Result local_matcher: None, route_key: None, })], - SubscriberOwnerStart::PostBlock(baseline.clone()), + SubscriberOwnerStart::PostBlock(baseline), )?; let epoch_b = subscriber.stage_interest_owner( HandlerId::new("bulk-pool-b"), @@ -218,7 +415,7 @@ async fn bulk_reconcile_routes_merged_backfill_to_exact_owner_epochs() -> Result )?; let progress = subscriber - .reconcile_interest_owners(&[epoch_a.clone(), epoch_b.clone()], through.clone()) + .reconcile_interest_owners(&[epoch_a.clone(), epoch_b.clone()], through) .await?; assert_eq!(progress.len(), 2); assert_eq!(progress[0].owner(), &epoch_a); @@ -255,7 +452,7 @@ async fn bulk_reconcile_routes_merged_backfill_to_exact_owner_epochs() -> Result #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn bulk_reconcile_preserves_exact_windows_for_mixed_owner_baselines() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let pool_a = Address::repeat_byte(0xb5); let pool_b = Address::repeat_byte(0xb6); let baseline_a = BlockRef { @@ -342,7 +539,7 @@ async fn bulk_reconcile_one_thousand_owners_uses_one_certification_and_bounded_l const OWNER_COUNT: usize = 1_024; const FILTERS_PER_CHUNK: usize = 256; - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let baseline = BlockRef { number: 200, hash: B256::repeat_byte(0xc8), @@ -376,12 +573,12 @@ async fn bulk_reconcile_one_thousand_owners_uses_one_certification_and_bounded_l local_matcher: None, route_key: None, })], - SubscriberOwnerStart::PostBlock(baseline.clone()), + SubscriberOwnerStart::PostBlock(baseline), )?); } let progress = subscriber - .reconcile_interest_owners(&epochs, through.clone()) + .reconcile_interest_owners(&epochs, through) .await?; assert_eq!(progress.len(), OWNER_COUNT); assert!( @@ -401,7 +598,7 @@ async fn bulk_reconcile_one_thousand_owners_uses_one_certification_and_bounded_l asserter.push_success(&Some(rpc_block(&through))); asserter.push_success(&Some(rpc_block(&through))); let current_point = subscriber - .reconcile_interest_owners(&epochs, through.clone()) + .reconcile_interest_owners(&epochs, through) .await?; assert_eq!(current_point.len(), OWNER_COUNT); assert!(current_point.iter().all(|item| item.through() == &through)); @@ -413,7 +610,7 @@ async fn bulk_reconcile_one_thousand_owners_uses_one_certification_and_bounded_l #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn bulk_reconcile_failure_commits_no_owner_records_or_progress() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let pool_a = Address::repeat_byte(0xb3); let pool_b = Address::repeat_byte(0xb4); let baseline = BlockRef { @@ -428,7 +625,7 @@ async fn bulk_reconcile_failure_commits_no_owner_records_or_progress() -> Result parent_hash: Some(B256::repeat_byte(0x2c)), timestamp: Some(1_700_000_301), }; - let mut reorged = through.clone(); + let mut reorged = through; reorged.hash = B256::repeat_byte(0xee); asserter.push_success(&U256::from(1)); @@ -445,7 +642,7 @@ async fn bulk_reconcile_failure_commits_no_owner_records_or_progress() -> Result local_matcher: None, route_key: None, })], - SubscriberOwnerStart::PostBlock(baseline.clone()), + SubscriberOwnerStart::PostBlock(baseline), )?); } @@ -480,7 +677,7 @@ async fn bulk_reconcile_failure_commits_no_owner_records_or_progress() -> Result #[cfg(feature = "reactive-polling")] async fn bulk_reconcile_rejects_conflicting_global_log_positions_across_chunks() -> Result<()> { const OWNER_COUNT: usize = 257; - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let baseline = BlockRef { number: 400, hash: B256::repeat_byte(0x90), @@ -517,7 +714,7 @@ async fn bulk_reconcile_rejects_conflicting_global_log_positions_across_chunks() local_matcher: None, route_key: None, })], - SubscriberOwnerStart::PostBlock(baseline.clone()), + SubscriberOwnerStart::PostBlock(baseline), )?); } @@ -545,7 +742,7 @@ async fn bulk_reconcile_rejects_conflicting_global_log_positions_across_chunks() async fn bulk_reconcile_globally_orders_canonical_logs_returned_by_different_chunks() -> Result<()> { const OWNER_COUNT: usize = 257; - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let baseline = BlockRef { number: 500, hash: B256::repeat_byte(0xa0), @@ -583,7 +780,7 @@ async fn bulk_reconcile_globally_orders_canonical_logs_returned_by_different_chu local_matcher: None, route_key: None, })], - SubscriberOwnerStart::PostBlock(baseline.clone()), + SubscriberOwnerStart::PostBlock(baseline), )?); } @@ -606,7 +803,7 @@ async fn bulk_reconcile_globally_orders_canonical_logs_returned_by_different_chu #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn empty_owner_reconcile_still_publishes_exact_hash_certified_progress() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let pool = Address::repeat_byte(0xa5); let through = BlockRef { number: 105, @@ -657,14 +854,10 @@ async fn empty_owner_reconcile_still_publishes_exact_hash_certified_progress() - }), )?; - let progress = subscriber - .reconcile_interest_owner(&epoch, through.clone()) - .await?; + let progress = subscriber.reconcile_interest_owner(&epoch, through).await?; assert_eq!(progress.through(), &through); assert_eq!(subscriber.interest_owner_progress(&epoch), Some(&progress)); - let next_progress = subscriber - .reconcile_interest_owner(&epoch, next.clone()) - .await?; + let next_progress = subscriber.reconcile_interest_owner(&epoch, next).await?; assert_eq!(next_progress.through(), &next); let batch = subscriber .next_scoped_batch() @@ -713,7 +906,7 @@ async fn empty_owner_reconcile_still_publishes_exact_hash_certified_progress() - #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn reconcile_at_baseline_certifies_progress_without_log_request() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let point = BlockRef { number: 100, hash: B256::repeat_byte(0x64), @@ -733,12 +926,10 @@ async fn reconcile_at_baseline_certifies_progress_without_log_request() -> Resul local_matcher: None, route_key: None, })], - SubscriberOwnerStart::PostBlock(point.clone()), + SubscriberOwnerStart::PostBlock(point), )?; - let progress = subscriber - .reconcile_interest_owner(&epoch, point.clone()) - .await?; + let progress = subscriber.reconcile_interest_owner(&epoch, point).await?; assert_eq!(progress.through(), &point); assert!(subscriber.activate_interest_owner(&epoch)); @@ -748,7 +939,7 @@ async fn reconcile_at_baseline_certifies_progress_without_log_request() -> Resul #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn reconcile_rejects_same_height_hash_replacement_before_provider_io() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let baseline = BlockRef { number: 100, hash: B256::repeat_byte(0x64), @@ -763,7 +954,7 @@ async fn reconcile_rejects_same_height_hash_replacement_before_provider_io() -> local_matcher: None, route_key: None, })], - SubscriberOwnerStart::PostBlock(baseline.clone()), + SubscriberOwnerStart::PostBlock(baseline), )?; let replacement = BlockRef { hash: B256::repeat_byte(0xee), @@ -790,7 +981,7 @@ async fn reconcile_rejects_same_height_hash_replacement_before_provider_io() -> #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn empty_interest_owner_reconciles_without_a_live_stream_topology() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let baseline = BlockRef { number: 100, hash: B256::repeat_byte(0x64), @@ -812,9 +1003,7 @@ async fn empty_interest_owner_reconciles_without_a_live_stream_topology() -> Res SubscriberOwnerStart::PostBlock(baseline), )?; - let progress = subscriber - .reconcile_interest_owner(&epoch, through.clone()) - .await?; + let progress = subscriber.reconcile_interest_owner(&epoch, through).await?; assert_eq!(progress.through(), &through); assert!(subscriber.activate_interest_owner(&epoch)); assert!(subscriber.next_scoped_batch().await?.is_none()); @@ -826,7 +1015,7 @@ async fn empty_interest_owner_reconciles_without_a_live_stream_topology() -> Res #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn multi_block_reconcile_rejects_a_replaced_retained_baseline() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let pool = Address::repeat_byte(0xa9); let baseline = BlockRef { number: 100, @@ -840,7 +1029,7 @@ async fn multi_block_reconcile_rejects_a_replaced_retained_baseline() -> Result< parent_hash: Some(B256::repeat_byte(0x68)), timestamp: Some(1_700_000_105), }; - let mut replaced_baseline = baseline.clone(); + let mut replaced_baseline = baseline; replaced_baseline.hash = B256::repeat_byte(0xee); // Subscribe, certify the requested target, then prove that the retained @@ -877,7 +1066,7 @@ async fn multi_block_reconcile_rejects_a_replaced_retained_baseline() -> Result< #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn reconcile_hash_mismatch_publishes_nothing_and_remains_abortable() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let pool = Address::repeat_byte(0xa6); let topic = keccak256(b"Swap()"); let expected = BlockRef { @@ -886,7 +1075,7 @@ async fn reconcile_hash_mismatch_publishes_nothing_and_remains_abortable() -> Re parent_hash: Some(B256::repeat_byte(0x64)), timestamp: Some(1_700_000_101), }; - let mut actual = expected.clone(); + let mut actual = expected; actual.hash = B256::repeat_byte(0xee); asserter.push_success(&U256::from(1)); asserter.push_success(&Some(rpc_block(&expected))); @@ -927,7 +1116,7 @@ async fn reconcile_hash_mismatch_publishes_nothing_and_remains_abortable() -> Re #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn reconcile_rejects_logs_without_canonical_transaction_position() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let pool = Address::repeat_byte(0xaa); let topic = keccak256(b"Swap()"); let baseline = BlockRef { @@ -976,7 +1165,7 @@ async fn reconcile_rejects_logs_without_canonical_transaction_position() -> Resu #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn reconcile_rejects_conflicting_transaction_identity_at_one_position() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let pool = Address::repeat_byte(0xad); let baseline = BlockRef { number: 100, @@ -1026,7 +1215,7 @@ async fn reconcile_rejects_conflicting_transaction_identity_at_one_position() -> #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn control_priority_preserves_already_ready_scoped_batch() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let pool = Address::repeat_byte(0xa7); let through = BlockRef { number: 101, @@ -1083,7 +1272,7 @@ async fn control_priority_preserves_already_ready_scoped_batch() -> Result<()> { fn mock_subscriber( mode: SubscriberMode, ) -> AlloySubscriber, Ethereum> { - let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let provider = ProviderBuilder::new().connect_mocked_client(asserter_with_chain_id()); AlloySubscriber::new(provider, mode, SubscriberConfig::default()) } @@ -1094,11 +1283,13 @@ async fn alloy_subscriber_auto_mode_uses_pubsub_by_default() -> Result<()> { let topic0 = keccak256(b"AutoMode(uint256)"); let mut subscriber = mock_subscriber(SubscriberMode::Auto); - subscriber.register_interests(&[ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(address).event_signature(topic0), - local_matcher: None, - route_key: None, - })])?; + subscriber + .register_interests(&[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address).event_signature(topic0), + local_matcher: None, + route_key: None, + })]) + .await?; assert_eq!(SubscriberMode::default(), SubscriberMode::Auto); assert_eq!(subscriber.registered_interests().len(), 1); @@ -1112,22 +1303,24 @@ async fn alloy_subscriber_auto_mode_uses_pubsub_by_default() -> Result<()> { Ok(()) } -#[test] +#[tokio::test] #[cfg(feature = "reactive-ws")] -fn alloy_subscriber_pubsub_accepts_logs_pending_hashes_and_block_headers() -> Result<()> { +async fn alloy_subscriber_pubsub_accepts_logs_pending_hashes_and_block_headers() -> Result<()> { let address = Address::repeat_byte(0xab); let topic0 = keccak256(b"SubscriberLog(uint256)"); let mut subscriber = mock_subscriber(SubscriberMode::PubSub); - subscriber.register_interests(&[ - ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(address).event_signature(topic0), - local_matcher: None, - route_key: None, - }), - ReactiveInterest::PendingTransactions(PendingTxInterest::default()), - ReactiveInterest::Blocks(BlockInterest::default()), - ])?; + subscriber + .register_interests(&[ + ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address).event_signature(topic0), + local_matcher: None, + route_key: None, + }), + ReactiveInterest::PendingTransactions(PendingTxInterest::default()), + ReactiveInterest::Blocks(BlockInterest::default()), + ]) + .await?; assert_eq!(subscriber.registered_interests().len(), 3); @@ -1300,7 +1493,7 @@ fn alloy_subscriber_prepares_cancels_and_finalizes_exact_owner_removal() -> Resu #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn staged_owner_abort_cleans_reconcile_state_after_provider_error() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let through = BlockRef { number: 51, hash: B256::repeat_byte(51), @@ -1390,24 +1583,26 @@ fn staging_rejects_non_log_post_block_interests() { } #[tokio::test(flavor = "multi_thread")] -#[cfg(feature = "reactive-ws")] -async fn alloy_subscriber_owner_backfill_yields_before_live_streams() -> Result<()> { - let asserter = Asserter::new(); +#[cfg(feature = "reactive-polling")] +async fn alloy_subscriber_installs_live_stream_before_owner_backfill() -> Result<()> { + let asserter = asserter_with_chain_id(); let pool = Address::repeat_byte(0xcd); let topic = keccak256(b"DiscoveredPool(uint256)"); let log = rpc_log(pool, topic, 42, 3); + let through = BlockRef { + number: 42, + hash: B256::repeat_byte(42), + parent_hash: Some(B256::repeat_byte(41)), + timestamp: Some(1_700_000_042), + }; + // Response ordering is part of the assertion: the live source is installed + // before the bounded historical window is fetched. + asserter.push_success(&U256::from(1)); + asserter.push_success(&Some(rpc_block(&through))); asserter.push_success(&vec![log.clone()]); + asserter.push_success(&Some(rpc_block(&through))); - let provider = ProviderBuilder::new().connect_mocked_client(asserter); - let mut subscriber = AlloySubscriber::new( - provider, - SubscriberMode::PubSub, - SubscriberConfig { - hydrate_pending_transactions: false, - max_batch_size: 16, - ..SubscriberConfig::default() - }, - ); + let mut subscriber = polling_subscriber(asserter.clone(), 16); subscriber.add_interest_owner_with_backfill( HandlerId::new("pool-cd"), &[ReactiveInterest::Logs(LogInterest { @@ -1419,21 +1614,82 @@ async fn alloy_subscriber_owner_backfill_yields_before_live_streams() -> Result< )?; let Some(batch) = subscriber.next_batch().await? else { - bail!("expected owner-scoped backfill batch before live subscription"); + bail!("expected owner-scoped backfill batch after live source installation"); }; let records = batch.records(); assert_eq!(records.len(), 1); + assert_eq!(subscriber.chain_id(), Some(1)); + assert_eq!(records[0].context.chain_id, Some(1)); assert_eq!(records[0].context.source, InputSource::Backfill); assert!( matches!(records[0].context.chain_status, ChainStatus::Included { ref block, confirmations: 0 } if block.number == 42) ); assert!(matches!(&records[0].input, ReactiveInput::Log(actual) if actual == &log)); + assert_eq!( + batch.record_audience(0), + Some(&DeliveryAudience::Owners(vec![HandlerId::new("pool-cd")])) + ); + assert_eq!( + batch.record_delivery_scope(0), + Some(DeliveryScope::OwnerCatchup) + ); + assert!( + asserter.read_q().is_empty(), + "unexpected owner-backfill RPCs" + ); Ok(()) } #[tokio::test(flavor = "multi_thread")] -#[cfg(feature = "reactive-ws")] +#[cfg(feature = "reactive-polling")] +async fn lazy_owner_backfill_rejects_incomplete_canonical_log_identity() -> Result<()> { + let asserter = asserter_with_chain_id(); + let pool = Address::repeat_byte(0xce); + let topic = keccak256(b"Malformed(uint256)"); + let mut malformed = rpc_log(pool, topic, 42, 3); + malformed.transaction_hash = None; + let through = BlockRef { + number: 42, + hash: B256::repeat_byte(42), + parent_hash: Some(B256::repeat_byte(41)), + timestamp: Some(1_700_000_042), + }; + asserter.push_success(&U256::from(1)); + asserter.push_success(&Some(rpc_block(&through))); + asserter.push_success(&vec![malformed]); + asserter.push_success(&Some(rpc_block(&through))); + let mut subscriber = polling_subscriber(asserter.clone(), 16); + subscriber.add_interest_owner_with_backfill( + HandlerId::new("malformed-owner"), + &[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(pool).event_signature(topic), + local_matcher: None, + route_key: None, + })], + SubscriberBackfill::range(40, 42), + )?; + + let error = subscriber + .next_batch() + .await + .expect_err("malformed canonical provider data must fail before delivery"); + match error { + SubscriberError::InvalidBackfill(message) => { + assert!(message.contains("transaction hash"), "{message}"); + } + other => bail!("expected invalid-backfill identity error, got {other}"), + } + assert_eq!( + asserter.read_q().len(), + 1, + "identity validation should fail before post-fetch canonical verification" + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "reactive-polling")] async fn alloy_subscriber_owner_growth_backfills_continuity_gap_end_to_end() -> Result<()> { // An established owner (pool A) has delivered up to block 100. Growing it to // also watch pool B changes the merged filter shape; the subscriber must @@ -1442,28 +1698,36 @@ async fn alloy_subscriber_owner_growth_backfills_continuity_gap_end_to_end() -> let pool_a = Address::repeat_byte(0xaa); let pool_b = Address::repeat_byte(0xbb); - let asserter = Asserter::new(); - // (1) Establish pool A's anchor at 100 via an explicit range backfill that - // returns a log — the returned record makes next_batch yield before it - // ever reaches live-stream init. + let asserter = asserter_with_chain_id(); + // (1) Install pool A's live filter, then establish its anchor at 100 via an + // explicit range backfill. let anchor_log = rpc_log(pool_a, keccak256(b"Swap()"), 100, 0); + let block_100 = BlockRef { + number: 100, + hash: B256::repeat_byte(100), + parent_hash: Some(B256::repeat_byte(99)), + timestamp: Some(1_700_000_100), + }; + asserter.push_success(&U256::from(1)); + asserter.push_success(&Some(rpc_block(&block_100))); asserter.push_success(&vec![anchor_log.clone()]); - // (2) Continuity backfill for the grown {A,B} shape is open-ended from the - // prior anchor: get_block_number, then get_logs over [100, 105]. + asserter.push_success(&Some(rpc_block(&block_100))); + // (2) Install the grown {A,B} live filter, then run its open-ended + // continuity backfill from the prior anchor over [100, 105]. + asserter.push_success(&U256::from(2)); asserter.push_success(&105u64); let gap_log = rpc_log(pool_b, keccak256(b"Swap()"), 103, 0); + let block_105 = BlockRef { + number: 105, + hash: B256::repeat_byte(105), + parent_hash: Some(B256::repeat_byte(104)), + timestamp: Some(1_700_000_105), + }; + asserter.push_success(&Some(rpc_block(&block_105))); asserter.push_success(&vec![gap_log.clone()]); + asserter.push_success(&Some(rpc_block(&block_105))); - let provider = ProviderBuilder::new().connect_mocked_client(asserter); - let mut subscriber = AlloySubscriber::new( - provider, - SubscriberMode::PubSub, - SubscriberConfig { - hydrate_pending_transactions: false, - max_batch_size: 16, - ..SubscriberConfig::default() - }, - ); + let mut subscriber = polling_subscriber(asserter.clone(), 16); // Establish pool A with an explicit backfill through block 100. subscriber.add_interest_owner_with_backfill( @@ -1506,50 +1770,60 @@ async fn alloy_subscriber_owner_growth_backfills_continuity_gap_end_to_end() -> assert_eq!(records.len(), 1); assert_eq!(records[0].context.source, InputSource::Backfill); assert!(matches!(&records[0].input, ReactiveInput::Log(actual) if actual == &gap_log)); + assert!( + asserter.read_q().is_empty(), + "unexpected owner-growth continuity RPCs" + ); Ok(()) } -#[test] +#[tokio::test] #[cfg(feature = "reactive-ws")] -fn alloy_subscriber_pubsub_rejects_full_body_modes() -> Result<()> { +async fn alloy_subscriber_pubsub_rejects_full_body_modes() -> Result<()> { let mut subscriber = mock_subscriber(SubscriberMode::PubSub); - let full_pending = subscriber.register_interests(&[ReactiveInterest::PendingTransactions( - PendingTxInterest { + let full_pending = subscriber + .register_interests(&[ReactiveInterest::PendingTransactions(PendingTxInterest { full_transactions: true, ..PendingTxInterest::default() - }, - )]); + })]) + .await; assert!(matches!(full_pending, Err(SubscriberError::Unsupported(_)))); let mut subscriber = mock_subscriber(SubscriberMode::PubSub); - let full_block = subscriber.register_interests(&[ReactiveInterest::Blocks(BlockInterest { - mode: BlockInterestMode::FullBlock, - })]); + let full_block = subscriber + .register_interests(&[ReactiveInterest::Blocks(BlockInterest { + mode: BlockInterestMode::FullBlock, + })]) + .await; assert!(matches!(full_block, Err(SubscriberError::Unsupported(_)))); Ok(()) } -#[test] +#[tokio::test] #[cfg(not(feature = "reactive-ws"))] -fn alloy_subscriber_pubsub_requires_ws_feature() -> Result<()> { +async fn alloy_subscriber_pubsub_requires_ws_feature() -> Result<()> { let mut subscriber = mock_subscriber(SubscriberMode::PubSub); - let result = subscriber.register_interests(&[ReactiveInterest::PendingTransactions( - PendingTxInterest::default(), - )]); + let result = subscriber + .register_interests(&[ReactiveInterest::PendingTransactions( + PendingTxInterest::default(), + )]) + .await; assert!(matches!(result, Err(SubscriberError::Unsupported(_)))); Ok(()) } -#[test] +#[tokio::test] #[cfg(not(feature = "reactive-polling"))] -fn alloy_subscriber_polling_requires_polling_feature() -> Result<()> { +async fn alloy_subscriber_polling_requires_polling_feature() -> Result<()> { let mut subscriber = mock_subscriber(SubscriberMode::Polling); - let result = subscriber.register_interests(&[ReactiveInterest::PendingTransactions( - PendingTxInterest::default(), - )]); + let result = subscriber + .register_interests(&[ReactiveInterest::PendingTransactions( + PendingTxInterest::default(), + )]) + .await; assert!(matches!(result, Err(SubscriberError::Unsupported(_)))); Ok(()) @@ -1558,7 +1832,7 @@ fn alloy_subscriber_polling_requires_polling_feature() -> Result<()> { #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn alloy_subscriber_polling_logs_yield_reactive_records() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let address = Address::repeat_byte(0xab); let topic0 = keccak256(b"SubscriberLog(uint256)"); let log = rpc_log(address, topic0, 42, 7); @@ -1567,11 +1841,13 @@ async fn alloy_subscriber_polling_logs_yield_reactive_records() -> Result<()> { asserter.push_success(&vec![log.clone()]); let mut subscriber = polling_subscriber(asserter, 16); - subscriber.register_interests(&[ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(address).event_signature(topic0), - local_matcher: None, - route_key: None, - })])?; + subscriber + .register_interests(&[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address).event_signature(topic0), + local_matcher: None, + route_key: None, + })]) + .await?; let Some(batch) = subscriber.next_batch().await? else { bail!("expected one batch from the polling log stream"); @@ -1592,7 +1868,7 @@ async fn alloy_subscriber_polling_logs_yield_reactive_records() -> Result<()> { #[tokio::test(flavor = "multi_thread")] #[cfg(all(feature = "reactive-polling", not(feature = "reactive-ws")))] async fn alloy_subscriber_auto_mode_uses_polling_when_ws_is_not_compiled() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let address = Address::repeat_byte(0xef); let topic0 = keccak256(b"AutoMode(uint256)"); let log = rpc_log(address, topic0, 50, 0); @@ -1610,11 +1886,13 @@ async fn alloy_subscriber_auto_mode_uses_polling_when_ws_is_not_compiled() -> Re ..SubscriberConfig::default() }, ); - subscriber.register_interests(&[ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(address).event_signature(topic0), - local_matcher: None, - route_key: None, - })])?; + subscriber + .register_interests(&[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address).event_signature(topic0), + local_matcher: None, + route_key: None, + })]) + .await?; let Some(batch) = subscriber.next_batch().await? else { bail!("expected auto mode to use polling and produce one batch"); @@ -1630,16 +1908,18 @@ async fn alloy_subscriber_auto_mode_uses_polling_when_ws_is_not_compiled() -> Re #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn alloy_subscriber_polling_pending_hashes_yield_pending_records() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let hash = B256::repeat_byte(0x55); asserter.push_success(&U256::from(2)); asserter.push_success(&vec![hash]); let mut subscriber = polling_subscriber(asserter, 16); - subscriber.register_interests(&[ReactiveInterest::PendingTransactions( - PendingTxInterest::default(), - )])?; + subscriber + .register_interests(&[ReactiveInterest::PendingTransactions( + PendingTxInterest::default(), + )]) + .await?; let Some(batch) = subscriber.next_batch().await? else { bail!("expected one batch from the polling pending transaction stream"); @@ -1659,7 +1939,7 @@ async fn alloy_subscriber_polling_pending_hashes_yield_pending_records() -> Resu #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn alloy_subscriber_removed_logs_yield_reorged_context() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let address = Address::repeat_byte(0x12); let topic0 = keccak256(b"Removed(uint256)"); let log = removed_rpc_log(address, topic0, 75, 2); @@ -1668,11 +1948,13 @@ async fn alloy_subscriber_removed_logs_yield_reorged_context() -> Result<()> { asserter.push_success(&vec![log.clone()]); let mut subscriber = polling_subscriber(asserter, 16); - subscriber.register_interests(&[ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(address).event_signature(topic0), - local_matcher: None, - route_key: None, - })])?; + subscriber + .register_interests(&[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address).event_signature(topic0), + local_matcher: None, + route_key: None, + })]) + .await?; let Some(batch) = subscriber.next_batch().await? else { bail!("expected removed log batch"); @@ -1690,7 +1972,7 @@ async fn alloy_subscriber_removed_logs_yield_reorged_context() -> Result<()> { #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn alloy_subscriber_respects_max_batch_size_for_polled_logs() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let address = Address::repeat_byte(0xcd); let topic0 = keccak256(b"Chunked(uint256)"); let first = rpc_log(address, topic0, 100, 0); @@ -1700,11 +1982,13 @@ async fn alloy_subscriber_respects_max_batch_size_for_polled_logs() -> Result<() asserter.push_success(&vec![first.clone(), second.clone()]); let mut subscriber = polling_subscriber(asserter, 1); - subscriber.register_interests(&[ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(address).event_signature(topic0), - local_matcher: None, - route_key: None, - })])?; + subscriber + .register_interests(&[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address).event_signature(topic0), + local_matcher: None, + route_key: None, + })]) + .await?; let Some(first_batch) = subscriber.next_batch().await? else { bail!("expected first chunk"); @@ -1725,12 +2009,13 @@ async fn alloy_subscriber_respects_max_batch_size_for_polled_logs() -> Result<() Ok(()) } -#[test] +#[tokio::test] #[cfg(feature = "reactive-polling")] -fn alloy_subscriber_polling_block_streams_are_explicitly_unsupported() -> Result<()> { +async fn alloy_subscriber_polling_block_streams_are_explicitly_unsupported() -> Result<()> { let mut polling = mock_subscriber(SubscriberMode::Polling); - let block_result = - polling.register_interests(&[ReactiveInterest::Blocks(BlockInterest::default())]); + let block_result = polling + .register_interests(&[ReactiveInterest::Blocks(BlockInterest::default())]) + .await; assert!(matches!(block_result, Err(SubscriberError::Unsupported(_)))); Ok(()) @@ -1739,16 +2024,18 @@ fn alloy_subscriber_polling_block_streams_are_explicitly_unsupported() -> Result #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn alloy_subscriber_provider_errors_are_reported() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let address = Address::repeat_byte(0x34); let topic0 = keccak256(b"ProviderError(uint256)"); let mut subscriber = polling_subscriber(asserter, 16); - subscriber.register_interests(&[ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(address).event_signature(topic0), - local_matcher: None, - route_key: None, - })])?; + subscriber + .register_interests(&[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address).event_signature(topic0), + local_matcher: None, + route_key: None, + })]) + .await?; let result = subscriber.next_batch().await; assert!(matches!(result, Err(SubscriberError::Provider(_)))); @@ -1759,7 +2046,7 @@ async fn alloy_subscriber_provider_errors_are_reported() -> Result<()> { #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-polling")] async fn alloy_subscriber_reports_dropped_polling_filters() -> Result<()> { - let asserter = Asserter::new(); + let asserter = asserter_with_chain_id(); let address = Address::repeat_byte(0x56); let topic0 = keccak256(b"DroppedFilter(uint256)"); @@ -1767,11 +2054,13 @@ async fn alloy_subscriber_reports_dropped_polling_filters() -> Result<()> { asserter.push_failure_msg("filter not found"); let mut subscriber = polling_subscriber(asserter, 16); - subscriber.register_interests(&[ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(address).event_signature(topic0), - local_matcher: None, - route_key: None, - })])?; + subscriber + .register_interests(&[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address).event_signature(topic0), + local_matcher: None, + route_key: None, + })]) + .await?; let result = subscriber.next_batch().await; assert!( @@ -1795,9 +2084,11 @@ async fn alloy_subscriber_zero_max_batch_size_is_rejected() -> Result<()> { }, ); - let result = subscriber.register_interests(&[ReactiveInterest::PendingTransactions( - PendingTxInterest::default(), - )]); + let result = subscriber + .register_interests(&[ReactiveInterest::PendingTransactions( + PendingTxInterest::default(), + )]) + .await; assert!( result.is_err(), @@ -1828,9 +2119,11 @@ async fn alloy_subscriber_rejects_invalid_reconnect_config() -> Result<()> { }, ); - let result = subscriber.register_interests(&[ReactiveInterest::PendingTransactions( - PendingTxInterest::default(), - )]); + let result = subscriber + .register_interests(&[ReactiveInterest::PendingTransactions( + PendingTxInterest::default(), + )]) + .await; assert!(matches!(result, Err(SubscriberError::InvalidConfig(_)))); let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); @@ -1846,9 +2139,11 @@ async fn alloy_subscriber_rejects_invalid_reconnect_config() -> Result<()> { }, ); - let result = subscriber.register_interests(&[ReactiveInterest::PendingTransactions( - PendingTxInterest::default(), - )]); + let result = subscriber + .register_interests(&[ReactiveInterest::PendingTransactions( + PendingTxInterest::default(), + )]) + .await; assert!(matches!(result, Err(SubscriberError::InvalidConfig(_)))); Ok(()) diff --git a/tests/reactive_async_registration.rs b/tests/reactive_async_registration.rs new file mode 100644 index 0000000..a99c519 --- /dev/null +++ b/tests/reactive_async_registration.rs @@ -0,0 +1,328 @@ +#![cfg(feature = "reactive")] + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use alloy_network::{Ethereum, Network}; +use alloy_primitives::Address; +use alloy_rpc_types_eth::Filter; +use evm_fork_cache::ReactiveEngine; +use evm_fork_cache::reactive::{ + EventSubscriber, HandlerError, HandlerId, HandlerOutcome, InterestOwnerSubscriber, LogInterest, + ReactiveConfig, ReactiveContext, ReactiveHandler, ReactiveInput, ReactiveInterest, + RouteKeySpec, StateEffectQuality, SubscriberBackfill, SubscriberError, SubscriberNextBatch, + SubscriberOperation, +}; + +struct DelayedSubscriber { + owners: HashMap>>, + registration_completed: bool, + block_registration: bool, + fail_removal: bool, +} + +impl Default for DelayedSubscriber { + fn default() -> Self { + Self { + owners: HashMap::new(), + registration_completed: false, + block_registration: false, + fail_removal: false, + } + } +} + +impl EventSubscriber for DelayedSubscriber +where + N: Network + Send + 'static, +{ + fn register_interests( + &mut self, + interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + if self.block_registration { + std::future::pending::<()>().await; + } + tokio::task::yield_now().await; + self.owners.clear(); + self.owners.insert(HandlerId::new("base"), interests); + Ok(()) + }) + } + + fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> { + Box::pin(async { Ok(None) }) + } +} + +impl InterestOwnerSubscriber for DelayedSubscriber +where + N: Network + Send + 'static, +{ + fn replace_interest_owners( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if self.block_registration { + std::future::pending::<()>().await; + } + tokio::task::yield_now().await; + self.owners = owners.into_iter().collect(); + Ok(()) + }) + } + + fn replace_interest_owners_with_global_backfill( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + _backfill: SubscriberBackfill, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if self.block_registration { + std::future::pending::<()>().await; + } + tokio::task::yield_now().await; + self.owners = owners.into_iter().collect(); + Ok(()) + }) + } + + fn add_interest_owner( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + if self.block_registration { + std::future::pending::<()>().await; + } + tokio::task::yield_now().await; + self.owners.insert(owner, interests); + self.registration_completed = true; + Ok(()) + }) + } + + fn add_interest_owner_with_backfill( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + _backfill: SubscriberBackfill, + ) -> SubscriberOperation<'_, ()> { + self.add_interest_owner(owner, interests) + } + + fn add_interest_owner_with_canonical_catchup( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + _retained: evm_fork_cache::reactive::BlockRef, + ) -> SubscriberOperation<'_, ()> { + self.add_interest_owner(owner, interests) + } + + fn remove_interest_owner( + &mut self, + owner: &HandlerId, + ) -> SubscriberOperation<'_, Option>>> { + let owner = owner.clone(); + Box::pin(async move { + tokio::task::yield_now().await; + if self.fail_removal { + return Err(SubscriberError::InvalidConfig("forced removal failure")); + } + Ok(self.owners.remove(&owner)) + }) + } + + fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest]> { + self.owners.get(owner).map(Vec::as_slice) + } +} + +struct NoopHandler { + id: HandlerId, + address: Address, +} + +impl ReactiveHandler for NoopHandler { + fn id(&self) -> HandlerId { + self.id.clone() + } + + fn interests(&self) -> Vec { + vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(self.address), + local_matcher: None, + route_key: Some(RouteKeySpec::EmitterAddress), + })] + } + + fn handle( + &self, + _ctx: &ReactiveContext, + _input: &ReactiveInput, + _state: &dyn evm_fork_cache::events::StateView, + ) -> Result { + Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect)) + } +} + +#[tokio::test] +async fn engine_registration_awaits_subscriber_completion() { + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + DelayedSubscriber::default(), + ); + let id = HandlerId::new("pool-a"); + + engine + .register_handler(Arc::new(NoopHandler { + id: id.clone(), + address: Address::repeat_byte(0xa1), + })) + .await + .expect("async subscriber registration should complete"); + + assert!(engine.subscriber().registration_completed); + assert!(engine.runtime().contains_handler(&id)); + assert!(engine.subscriber().owner_interests(&id).is_some()); +} + +#[tokio::test] +async fn engine_removal_failure_preserves_runtime_and_subscriber_owner() { + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + DelayedSubscriber::default(), + ); + let id = HandlerId::new("pool-a"); + engine + .register_handler(Arc::new(NoopHandler { + id: id.clone(), + address: Address::repeat_byte(0xa1), + })) + .await + .expect("registration should complete"); + engine.subscriber_mut().fail_removal = true; + + let error = match engine.unregister_handler(&id).await { + Ok(_) => panic!("subscriber removal failure should surface"), + Err(error) => error, + }; + + assert!(matches!(error, SubscriberError::InvalidConfig(_))); + assert!(engine.runtime().contains_handler(&id)); + assert!(engine.subscriber().owner_interests(&id).is_some()); +} + +#[tokio::test] +async fn cancelled_registration_does_not_commit_runtime_handler() { + let subscriber = DelayedSubscriber:: { + block_registration: true, + ..Default::default() + }; + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + let id = HandlerId::new("pool-a"); + + let mut registration = Box::pin(engine.register_handler(Arc::new(NoopHandler { + id: id.clone(), + address: Address::repeat_byte(0xa1), + }))); + assert!( + tokio::time::timeout(Duration::from_millis(10), registration.as_mut()) + .await + .is_err(), + "test subscriber should keep registration pending" + ); + drop(registration); + + assert!(!engine.runtime().contains_handler(&id)); + assert!(engine.subscriber().owner_interests(&id).is_none()); +} + +#[tokio::test] +async fn cancelled_removal_preserves_runtime_and_subscriber_owner() { + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + DelayedSubscriber::default(), + ); + let id = HandlerId::new("pool-a"); + engine + .register_handler(Arc::new(NoopHandler { + id: id.clone(), + address: Address::repeat_byte(0xa1), + })) + .await + .expect("registration should complete"); + + let mut removal = Box::pin(engine.unregister_handler(&id)); + assert!( + futures::poll!(removal.as_mut()).is_pending(), + "removal must pause at the subscriber commit boundary" + ); + drop(removal); + + assert!(engine.runtime().contains_handler(&id)); + assert!(engine.subscriber().owner_interests(&id).is_some()); +} + +#[tokio::test] +async fn cancelled_exact_owner_replacement_preserves_previous_topology() { + let stale = HandlerId::new("crash-stale"); + let mut subscriber = DelayedSubscriber:: { + block_registration: true, + ..Default::default() + }; + subscriber.owners.insert(stale.clone(), Vec::new()); + let baseline = evm_fork_cache::reactive::BlockRef { + number: 100, + hash: alloy_primitives::B256::repeat_byte(100), + parent_hash: Some(alloy_primitives::B256::repeat_byte(99)), + timestamp: Some(1_700_000_100), + }; + let backfill = SubscriberBackfill::after_canonical_block(baseline).expect("C + 1"); + + let mut replacement = Box::pin(subscriber.replace_interest_owners_with_global_backfill( + vec![(HandlerId::new("pool-a"), Vec::new())], + backfill, + )); + assert!( + tokio::time::timeout(Duration::from_millis(10), replacement.as_mut()) + .await + .is_err(), + "test subscriber should keep replacement pending" + ); + drop(replacement); + + assert_eq!(subscriber.owners.len(), 1); + assert!(subscriber.owners.contains_key(&stale)); +} + +#[tokio::test] +async fn cancelled_fresh_owner_replacement_preserves_previous_topology() { + let stale = HandlerId::new("crash-stale-fresh"); + let mut subscriber = DelayedSubscriber:: { + block_registration: true, + ..Default::default() + }; + subscriber.owners.insert(stale.clone(), Vec::new()); + + let mut replacement = + Box::pin(subscriber.replace_interest_owners(vec![(HandlerId::new("pool-a"), Vec::new())])); + assert!( + tokio::time::timeout(Duration::from_millis(10), replacement.as_mut()) + .await + .is_err(), + "test subscriber should keep replacement pending" + ); + drop(replacement); + + assert_eq!(subscriber.owners.len(), 1); + assert!(subscriber.owners.contains_key(&stale)); +} diff --git a/tests/reactive_engine.rs b/tests/reactive_engine.rs index 25b4864..724b346 100644 --- a/tests/reactive_engine.rs +++ b/tests/reactive_engine.rs @@ -8,21 +8,27 @@ use std::{ sync::Arc, }; +use alloy_eips::BlockId; use alloy_network::{Ethereum, Network}; -use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, keccak256}; +use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256, keccak256}; use alloy_rpc_types_eth::{Filter, Log}; use common::{install_mock_erc20, setup_cache}; use evm_fork_cache::events::StateView; use evm_fork_cache::reactive::{ - AccountFieldMask, BlockRef, ChainStatus, EventSubscriber, HandlerError, HandlerId, - HandlerOutcome, InputSource, InterestOwnerSubscriber, LogInterest, ReactiveConfig, - ReactiveContext, ReactiveEffect, ReactiveEngine, ReactiveEngineRegisterError, ReactiveHandler, - ReactiveInput, ReactiveInputBatch, ReactiveInputRecord, ReactiveInterest, ReactiveRegistry, - ReactiveReport, RegisterError, ResyncBlock, ResyncId, ResyncPriority, ResyncReason, - ResyncRequest, ResyncTarget, RouteKeySpec, StateEffectQuality, SubscriberBackfill, - SubscriberError, SubscriberNextBatch, + AccountFieldMask, BlockRef, ChainControl, ChainStatus, DeliveryAudience, DeliveryScope, + EventSubscriber, HandlerError, HandlerId, HandlerOutcome, InputRef, InputSource, + InterestOwnerSubscriber, LogInterest, ReactiveBaselineError, ReactiveCanonicalBaseline, + ReactiveConfig, ReactiveContext, ReactiveEffect, ReactiveEngine, ReactiveEngineError, + ReactiveEngineRegisterError, ReactiveError, ReactiveHandler, ReactiveInput, ReactiveInputBatch, + ReactiveInputDelivery, ReactiveInputRecord, ReactiveInterest, ReactiveRegistry, ReactiveReport, + RegisterError, ResyncBlock, ResyncId, ResyncPriority, ResyncReason, ResyncRequest, + ResyncTarget, RouteKeySpec, StateEffectQuality, SubscriberBackfill, SubscriberCapabilities, + SubscriberCheckpoint, SubscriberDeliveryToken, SubscriberError, SubscriberNextBatch, + SubscriberOperation, }; +use evm_fork_cache::state_update::StateUpdate; +use evm_fork_cache::{DurableCheckpointIdentity, DurableCheckpointStore}; fn rpc_log(address: Address, topic0: B256, block_number: u64) -> Log { Log { @@ -48,7 +54,7 @@ fn included_context(block_number: u64) -> ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -64,11 +70,33 @@ fn canonical_log_batch(address: Address, block_number: u64) -> ReactiveInputBatc )]) } +fn owner_catchup_log_batch( + address: Address, + block_number: u64, + owner: HandlerId, +) -> ReactiveInputBatch { + ReactiveInputBatch::from_deliveries([ReactiveInputDelivery::new( + ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log(address, keccak256(b"Event()"), block_number)), + included_context(block_number), + ), + DeliveryAudience::Owners(vec![owner]), + DeliveryScope::OwnerCatchup, + )]) +} + struct RecordingSubscriber { full_replace_interests: Vec>, owners: HashMap>>, backfills: Vec<(HandlerId, SubscriberBackfill)>, + coordinated_catchups: Vec<(HandlerId, BlockRef)>, batches: VecDeque>, + acknowledged: Vec, + bulk_upserts: usize, + bulk_replacements: usize, + replacement_backfill: Option, + fail_acknowledgement: bool, + hold_acknowledgement: bool, fail_owner: Option, } @@ -78,7 +106,14 @@ impl Default for RecordingSubscriber { full_replace_interests: Vec::new(), owners: HashMap::new(), backfills: Vec::new(), + coordinated_catchups: Vec::new(), batches: VecDeque::new(), + acknowledged: Vec::new(), + bulk_upserts: 0, + bulk_replacements: 0, + replacement_backfill: None, + fail_acknowledgement: false, + hold_acknowledgement: false, fail_owner: None, } } @@ -100,31 +135,422 @@ where fn register_interests( &mut self, interests: &[ReactiveInterest], - ) -> Result<(), SubscriberError> { - self.full_replace_interests = interests.to_vec(); - self.owners.clear(); - Ok(()) + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + self.full_replace_interests = interests; + self.owners.clear(); + Ok(()) + }) } fn next_batch(&mut self) -> SubscriberNextBatch<'_, N> { Box::pin(async move { Ok(self.batches.pop_front()) }) } + + fn acknowledge_delivery( + &mut self, + token: SubscriberDeliveryToken, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if self.hold_acknowledgement { + std::future::pending::<()>().await; + } + if self.fail_acknowledgement { + return Err(SubscriberError::InvalidConfig( + "forced acknowledgement failure", + )); + } + self.acknowledged.push(token); + Ok(()) + }) + } +} + +#[test] +fn subscriber_capabilities_fail_closed_by_default() { + let subscriber = RecordingSubscriber::::default(); + assert_eq!(subscriber.capabilities(), SubscriberCapabilities::default()); + assert!(!subscriber.capabilities().supports_live()); + assert!(!subscriber.capabilities().supports_durable_replay()); + assert!(!subscriber.capabilities().supports_explicit_reorgs()); +} + +#[tokio::test] +async fn checkpointed_ingest_rejects_non_durable_subscriber_before_polling() { + let emitter = Address::repeat_byte(0xda); + let subscriber = RecordingSubscriber { + batches: VecDeque::from([canonical_log_batch(emitter, 1)]), + ..RecordingSubscriber::default() + }; + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + let mut cache = setup_cache().await.expect("cache"); + let path = std::env::temp_dir().join(format!( + "evm-fork-cache-nondurable-{}-checkpoint.bin", + std::process::id() + )); + let store = DurableCheckpointStore::new(path); + let identity = DurableCheckpointIdentity::new(1, "ephemeral", "handlers-v1"); + + let error = engine + .next_ingest_checkpointed(&mut cache, &store, &identity) + .await + .expect_err("ephemeral delivery cannot be presented as restart safe"); + + assert!(matches!(error, ReactiveEngineError::SubscriberNotDurable)); + assert_eq!(engine.subscriber().batches.len(), 1); + assert!(engine.runtime().last_canonical_block().is_none()); +} + +#[tokio::test] +async fn engine_rechecks_lazily_resolved_chain_before_control_only_ingest() { + struct LazyWrongChainSubscriber { + chain_id: Option, + batch: Option>, + } + impl EventSubscriber for LazyWrongChainSubscriber { + fn chain_id(&self) -> Option { + self.chain_id + } + + fn register_interests( + &mut self, + _interests: &[ReactiveInterest], + ) -> SubscriberOperation<'_, ()> { + Box::pin(async { Ok(()) }) + } + + fn next_batch(&mut self) -> SubscriberNextBatch<'_, Ethereum> { + Box::pin(async move { + self.chain_id = Some(2); + Ok(self.batch.take()) + }) + } + } + + let progress = BlockRef { + number: 10, + hash: B256::repeat_byte(10), + parent_hash: Some(B256::repeat_byte(9)), + timestamp: Some(1_700_000_010), + }; + let subscriber = LazyWrongChainSubscriber { + chain_id: None, + batch: Some( + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(2) + .with_chain_controls([ChainControl::CanonicalProgress(progress)]), + ), + }; + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + let mut cache = setup_cache().await.expect("cache"); + + let error = engine + .next_ingest(&mut cache) + .await + .expect_err("wrong-chain control must be rejected after lazy identity resolution"); + + assert!(matches!( + error, + ReactiveEngineError::SubscriberChainMismatch { + subscriber_chain_id: 2, + cache_chain_id: 1 + } + )); + assert!(engine.runtime().last_canonical_block().is_none()); +} + +#[tokio::test] +async fn runtime_public_ingest_paths_reject_owner_catchup_outside_journal() { + let emitter = Address::repeat_byte(0xa1); + let owner = HandlerId::new("pool-a"); + let mut cache = setup_cache().await.expect("cache"); + let generation = cache.snapshot_generation(); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(NoopHandler::new("pool-a", emitter))) + .expect("register handler"); + + for with_resync in [false, true] { + let batch = owner_catchup_log_batch(emitter, 50, owner.clone()); + let error = if with_resync { + runtime + .ingest_batch_with_resync(&mut cache, batch) + .expect_err("owner catch-up outside journal must fail") + } else { + runtime + .ingest_batch(&mut cache, batch) + .expect_err("owner catch-up outside journal must fail") + }; + assert!(matches!( + error, + ReactiveError::OwnerCatchupOutsideJournal { number: 50, .. } + )); + assert_eq!(runtime.last_canonical_block(), None); + assert_eq!(cache.snapshot_generation(), generation); + } +} + +#[tokio::test] +async fn engine_public_ingest_paths_map_owner_catchup_guard() { + let emitter = Address::repeat_byte(0xa1); + let owner = HandlerId::new("pool-a"); + let mut cache = setup_cache().await.expect("cache"); + let generation = cache.snapshot_generation(); + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + RecordingSubscriber::default(), + ); + engine + .register_handler(Arc::new(NoopHandler::new("pool-a", emitter))) + .await + .expect("register handler"); + + for with_resync in [false, true] { + let batch = owner_catchup_log_batch(emitter, 50, owner.clone()); + let error = if with_resync { + engine + .ingest_batch_with_resync(&mut cache, batch) + .expect_err("owner catch-up outside journal must fail") + } else { + engine + .ingest_batch(&mut cache, batch) + .expect_err("owner catch-up outside journal must fail") + }; + assert!(matches!( + error, + ReactiveEngineError::OwnerCatchupOutsideJournal { number: 50, .. } + )); + assert_eq!(engine.runtime().last_canonical_block(), None); + assert_eq!(cache.snapshot_generation(), generation); + } +} + +#[tokio::test] +async fn combined_poll_ingest_paths_reject_owner_catchup_outside_journal() { + let emitter = Address::repeat_byte(0xa1); + let owner = HandlerId::new("pool-a"); + + for with_resync in [false, true] { + let mut cache = setup_cache().await.expect("cache"); + let generation = cache.snapshot_generation(); + let mut subscriber = RecordingSubscriber::default(); + subscriber + .batches + .push_back(owner_catchup_log_batch(emitter, 50, owner.clone())); + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + engine + .register_handler(Arc::new(NoopHandler::new("pool-a", emitter))) + .await + .expect("register handler"); + + let error = if with_resync { + engine + .next_ingest_with_resync(&mut cache) + .await + .expect_err("owner catch-up outside journal must fail") + } else { + engine + .next_ingest(&mut cache) + .await + .expect_err("owner catch-up outside journal must fail") + }; + assert!(matches!( + error, + ReactiveEngineError::OwnerCatchupOutsideJournal { number: 50, .. } + )); + assert_eq!(engine.runtime().last_canonical_block(), None); + assert_eq!(cache.snapshot_generation(), generation); + } +} + +#[tokio::test] +async fn owner_catchup_rejected_when_same_batch_reorg_drops_its_journal_entry() { + let emitter = Address::repeat_byte(0xa1); + let owner = HandlerId::new("pool-a"); + let mut cache = setup_cache().await.expect("cache"); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(NoopHandler::new("pool-a", emitter))) + .expect("register handler"); + runtime + .ingest_batch(&mut cache, canonical_log_batch(emitter, 49)) + .expect("seed ancestor"); + runtime + .ingest_batch(&mut cache, canonical_log_batch(emitter, 50)) + .expect("seed old tip"); + let old_tip = included_context(50).block.expect("old tip"); + let common_ancestor = included_context(49).block.expect("ancestor"); + let new_tip = BlockRef { + number: 50, + hash: B256::repeat_byte(0xfe), + parent_hash: Some(common_ancestor.hash), + timestamp: old_tip.timestamp, + }; + let generation = cache.snapshot_generation(); + let batch = + owner_catchup_log_batch(emitter, 50, owner).with_chain_controls([ChainControl::Reorg { + common_ancestor, + old_tip, + new_tip, + }]); + + let error = runtime + .ingest_batch(&mut cache, batch) + .expect_err("same-batch reorg invalidates owner rollback entry"); + assert!(matches!( + error, + ReactiveError::OwnerCatchupOutsideJournal { number: 50, .. } + )); + assert_eq!(runtime.last_canonical_block(), Some(old_tip)); + assert_eq!(cache.snapshot_generation(), generation); +} + +#[tokio::test] +async fn owner_catchup_rejects_conflicting_retained_block_metadata_before_handlers() { + let emitter = Address::repeat_byte(0xa1); + let owner = HandlerId::new("pool-a"); + + for conflict in ["parent", "context-timestamp", "payload-timestamp"] { + let mut cache = setup_cache().await.expect("cache"); + let mut runtime = + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(NoopHandler::new("pool-a", emitter))) + .expect("register handler"); + runtime + .ingest_batch(&mut cache, canonical_log_batch(emitter, 50)) + .expect("seed retained journal block"); + let retained = runtime.last_canonical_block().expect("retained block"); + let conflicting = BlockRef { + parent_hash: (conflict == "parent") + .then_some(B256::repeat_byte(0xfd)) + .or(retained.parent_hash), + timestamp: match conflict { + "context-timestamp" => Some(retained.timestamp.expect("timestamp") + 1), + "payload-timestamp" => None, + _ => retained.timestamp, + }, + ..retained + }; + let mut log = rpc_log(emitter, keccak256(b"Event()"), 50); + log.block_timestamp = if conflict == "payload-timestamp" { + Some(retained.timestamp.expect("timestamp") + 1) + } else { + conflicting.timestamp + }; + let context = ReactiveContext { + chain_id: Some(1), + source: InputSource::Backfill, + chain_status: ChainStatus::Included { + block: conflicting, + confirmations: 0, + }, + block: Some(conflicting), + transaction_index: log.transaction_index, + log_index: log.log_index, + }; + let batch = ReactiveInputBatch::from_deliveries([ReactiveInputDelivery::new( + ReactiveInputRecord::new(ReactiveInput::Log(log), context), + DeliveryAudience::Owners(vec![owner.clone()]), + DeliveryScope::OwnerCatchup, + )]); + let generation = cache.snapshot_generation(); + + let error = runtime + .ingest_batch(&mut cache, batch) + .expect_err("conflicting optional block metadata must fail closed"); + assert!(matches!( + error, + ReactiveError::OwnerCatchupOutsideJournal { number: 50, .. } + )); + assert_eq!(runtime.last_canonical_block(), Some(retained)); + assert_eq!(cache.snapshot_generation(), generation); + } } impl InterestOwnerSubscriber for RecordingSubscriber where N: Network + Send + 'static, { + fn upsert_interest_owners( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if owners + .iter() + .any(|(owner, _)| self.fail_owner.as_ref() == Some(owner)) + { + return Err(SubscriberError::InvalidConfig("forced owner failure")); + } + for (owner, interests) in owners { + self.owners.insert(owner, interests); + } + self.bulk_upserts += 1; + Ok(()) + }) + } + + fn replace_interest_owners( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if owners + .iter() + .any(|(owner, _)| self.fail_owner.as_ref() == Some(owner)) + { + return Err(SubscriberError::InvalidConfig("forced owner failure")); + } + self.owners = owners.into_iter().collect(); + self.replacement_backfill = None; + self.bulk_replacements += 1; + Ok(()) + }) + } + + fn replace_interest_owners_with_global_backfill( + &mut self, + owners: Vec<(HandlerId, Vec>)>, + backfill: SubscriberBackfill, + ) -> SubscriberOperation<'_, ()> { + Box::pin(async move { + if owners + .iter() + .any(|(owner, _)| self.fail_owner.as_ref() == Some(owner)) + { + return Err(SubscriberError::InvalidConfig("forced owner failure")); + } + self.owners = owners.into_iter().collect(); + self.replacement_backfill = Some(backfill); + self.bulk_replacements += 1; + Ok(()) + }) + } + fn add_interest_owner( &mut self, owner: HandlerId, interests: &[ReactiveInterest], - ) -> Result<(), SubscriberError> { - if self.fail_owner.as_ref() == Some(&owner) { - return Err(SubscriberError::InvalidConfig("forced owner failure")); - } - self.owners.insert(owner, interests.to_vec()); - Ok(()) + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + if self.fail_owner.as_ref() == Some(&owner) { + return Err(SubscriberError::InvalidConfig("forced owner failure")); + } + self.owners.insert(owner, interests); + Ok(()) + }) } fn add_interest_owner_with_backfill( @@ -132,14 +558,41 @@ where owner: HandlerId, interests: &[ReactiveInterest], backfill: SubscriberBackfill, - ) -> Result<(), SubscriberError> { - self.add_interest_owner(owner.clone(), interests)?; - self.backfills.push((owner, backfill)); - Ok(()) + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + if self.fail_owner.as_ref() == Some(&owner) { + return Err(SubscriberError::InvalidConfig("forced owner failure")); + } + self.owners.insert(owner.clone(), interests); + self.backfills.push((owner, backfill)); + Ok(()) + }) } - fn remove_interest_owner(&mut self, owner: &HandlerId) -> Option>> { - self.owners.remove(owner) + fn add_interest_owner_with_canonical_catchup( + &mut self, + owner: HandlerId, + interests: &[ReactiveInterest], + retained: BlockRef, + ) -> SubscriberOperation<'_, ()> { + let interests = interests.to_vec(); + Box::pin(async move { + if self.fail_owner.as_ref() == Some(&owner) { + return Err(SubscriberError::InvalidConfig("forced owner failure")); + } + self.owners.insert(owner.clone(), interests); + self.coordinated_catchups.push((owner, retained)); + Ok(()) + }) + } + + fn remove_interest_owner( + &mut self, + owner: &HandlerId, + ) -> SubscriberOperation<'_, Option>>> { + let owner = owner.clone(); + Box::pin(async move { Ok(self.owners.remove(&owner)) }) } fn owner_interests(&self, owner: &HandlerId) -> Option<&[ReactiveInterest]> { @@ -184,8 +637,49 @@ impl ReactiveHandler for NoopHandler { } } -#[test] -fn engine_register_handler_updates_runtime_and_subscriber() { +struct SlotWriter { + id: HandlerId, + address: Address, + slot: U256, + value: U256, +} + +impl ReactiveHandler for SlotWriter { + fn id(&self) -> HandlerId { + self.id.clone() + } + + fn interests(&self) -> Vec { + vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(self.address), + local_matcher: None, + route_key: None, + })] + } + + fn handle( + &self, + _ctx: &ReactiveContext, + input: &ReactiveInput, + _state: &dyn StateView, + ) -> Result { + let ReactiveInput::Log(log) = input else { + return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect)); + }; + Ok(HandlerOutcome { + effects: vec![ReactiveEffect::StateUpdate(StateUpdate::slot( + log.address(), + self.slot, + self.value, + ))], + quality: StateEffectQuality::ExactFromInput, + tags: Vec::new(), + }) + } +} + +#[tokio::test] +async fn engine_register_handler_updates_runtime_and_subscriber() { let mut engine = ReactiveEngine::new( evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), RecordingSubscriber::default(), @@ -196,6 +690,7 @@ fn engine_register_handler_updates_runtime_and_subscriber() { "pool-a", Address::repeat_byte(0xa1), ))) + .await .expect("engine registration should succeed"); assert!(engine.runtime().contains_handler(&HandlerId::new("pool-a"))); @@ -209,8 +704,442 @@ fn engine_register_handler_updates_runtime_and_subscriber() { ); } +#[tokio::test] +async fn owner_scoped_batch_routes_only_to_named_handlers() { + let emitter = Address::repeat_byte(0xa2); + let mut cache = setup_cache().await.expect("cache"); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + for id in ["existing", "new-owner"] { + runtime + .register_handler(Arc::new(NoopHandler::new(id, emitter))) + .expect("register handler"); + } + + let report = runtime + .ingest_batch( + &mut cache, + canonical_log_batch(emitter, 12) + .with_audience(DeliveryAudience::Owners(vec![HandlerId::new("new-owner")])), + ) + .expect("owner-scoped batch"); + + let decoded = report + .reports + .iter() + .find_map(|report| match report.as_ref() { + ReactiveReport::Decoded(decoded) => Some(decoded.handler_ids.clone()), + _ => None, + }); + assert_eq!(decoded, Some(vec![HandlerId::new("new-owner")])); +} + +#[tokio::test] +async fn runtime_rejects_payload_and_context_block_identity_disagreement_atomically() { + let emitter = Address::repeat_byte(0xa7); + let mut cache = setup_cache().await.expect("cache"); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(NoopHandler::new("mismatch", emitter))) + .expect("handler"); + + let record = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log(emitter, keccak256(b"Mismatch()"), 21)), + included_context(20), + ); + let error = runtime + .ingest_batch(&mut cache, ReactiveInputBatch::new(vec![record])) + .expect_err("a source cannot journal one block while delivering another"); + + assert!(matches!(error, ReactiveError::InvalidInputRecord { .. })); + assert!(runtime.last_canonical_block().is_none()); +} + +#[tokio::test] +async fn runtime_rejects_conflicting_payloads_claiming_one_stable_identity() { + let emitter = Address::repeat_byte(0xa8); + let mut cache = setup_cache().await.expect("cache"); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + let mut first = rpc_log(emitter, keccak256(b"First()"), 22); + let mut conflicting = rpc_log(emitter, keccak256(b"Conflicting()"), 22); + // Be explicit that every stable identity field is identical while the + // event payload differs. + conflicting.block_hash = first.block_hash; + conflicting.transaction_hash = first.transaction_hash; + conflicting.log_index = first.log_index; + first.transaction_index = Some(0); + conflicting.transaction_index = Some(0); + + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ + ReactiveInputRecord::new(ReactiveInput::Log(first), included_context(22)), + ReactiveInputRecord::new(ReactiveInput::Log(conflicting), included_context(22)), + ]), + ) + .expect_err("same-identity payload conflicts must fail closed"); + + assert!(matches!(error, ReactiveError::InvalidInputRecord { .. })); + assert!(runtime.last_canonical_block().is_none()); +} + +#[test] +fn compatible_duplicate_merge_is_order_independent_and_preserves_enrichment() { + let emitter = Address::repeat_byte(0xa8); + let mut historical_log = rpc_log(emitter, keccak256(b"Enriched()"), 24); + historical_log.block_timestamp = None; + let mut historical_context = included_context(24); + historical_context.source = InputSource::Backfill; + historical_context.block.as_mut().expect("block").timestamp = None; + historical_context.chain_status = ChainStatus::Included { + block: *historical_context.block.as_ref().expect("block"), + confirmations: 2, + }; + let historical: ReactiveInputRecord = + ReactiveInputRecord::new(ReactiveInput::Log(historical_log), historical_context); + + let mut finalized_context = included_context(24); + finalized_context.source = InputSource::Subscription; + finalized_context.chain_status = ChainStatus::Finalized { + block: *finalized_context.block.as_ref().expect("block"), + }; + let finalized = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log(emitter, keccak256(b"Enriched()"), 24)), + finalized_context, + ); + + let mut left_first = historical.clone(); + assert!( + left_first + .merge_compatible_duplicate(&finalized) + .expect("compatible overlap") + ); + let mut right_first = finalized; + assert!( + right_first + .merge_compatible_duplicate(&historical) + .expect("compatible reverse overlap") + ); + + assert_eq!(left_first.context, right_first.context); + assert!(matches!( + (&left_first.input, &right_first.input), + (ReactiveInput::Log(left), ReactiveInput::Log(right)) if left == right + )); + assert!(matches!( + left_first.context.chain_status, + ChainStatus::Finalized { .. } + )); + assert_eq!(left_first.context.source, InputSource::Subscription); + assert!( + left_first + .context + .block + .expect("enriched block") + .timestamp + .is_some() + ); +} + +#[test] +fn duplicate_merge_rejects_diagonal_payload_context_timestamp_conflicts_atomically() { + let emitter = Address::repeat_byte(0xa9); + let payload_timestamp_log = rpc_log(emitter, keccak256(b"Diagonal()"), 25); + let payload_timestamp = payload_timestamp_log.block_timestamp.expect("timestamp"); + let mut partial_context = included_context(25); + partial_context.block.as_mut().expect("block").timestamp = None; + if let ChainStatus::Included { block, .. } = &mut partial_context.chain_status { + block.timestamp = None; + } + let original_context = partial_context.clone(); + let original_log = payload_timestamp_log.clone(); + let mut retained: ReactiveInputRecord = + ReactiveInputRecord::new(ReactiveInput::Log(payload_timestamp_log), partial_context); + + let mut context_timestamp_log = rpc_log(emitter, keccak256(b"Diagonal()"), 25); + context_timestamp_log.block_timestamp = None; + let mut conflicting_context = included_context(25); + let conflicting_timestamp = payload_timestamp + 1; + conflicting_context.block.as_mut().expect("block").timestamp = Some(conflicting_timestamp); + if let ChainStatus::Included { block, .. } = &mut conflicting_context.chain_status { + block.timestamp = Some(conflicting_timestamp); + } + let incoming = ReactiveInputRecord::new( + ReactiveInput::Log(context_timestamp_log), + conflicting_context, + ); + + let error = retained + .merge_compatible_duplicate(&incoming) + .expect_err("the merged payload/context candidate is contradictory"); + assert!(matches!(error, ReactiveError::InvalidInputRecord { .. })); + assert_eq!(retained.context, original_context); + assert!(matches!( + &retained.input, + ReactiveInput::Log(log) if log == &original_log + )); +} + +#[tokio::test] +async fn all_except_audience_routes_to_every_other_matching_handler() { + let emitter = Address::repeat_byte(0xa4); + let mut cache = setup_cache().await.expect("cache"); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + for id in ["excluded", "included-a", "included-b"] { + runtime + .register_handler(Arc::new(NoopHandler::new(id, emitter))) + .expect("register handler"); + } + + let report = runtime + .ingest_batch( + &mut cache, + canonical_log_batch(emitter, 13).with_audience(DeliveryAudience::AllExcept(vec![ + HandlerId::new("excluded"), + ])), + ) + .expect("residual canonical delivery"); + + let decoded = report + .reports + .iter() + .find_map(|report| match report.as_ref() { + ReactiveReport::Decoded(decoded) => Some(decoded.handler_ids.clone()), + _ => None, + }) + .expect("decoded report"); + assert_eq!( + decoded, + vec![HandlerId::new("included-a"), HandlerId::new("included-b")] + ); +} + +#[tokio::test] +async fn owner_catchup_and_live_residual_overlap_execute_each_handler_once() { + let emitter = Address::repeat_byte(0xa5); + let mut cache = setup_cache().await.expect("cache"); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + for id in ["existing", "new-owner"] { + runtime + .register_handler(Arc::new(NoopHandler::new(id, emitter))) + .expect("register handler"); + } + let record = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log(emitter, keccak256(b"Event()"), 14)), + included_context(14), + ); + let batch = ReactiveInputBatch::from_deliveries([ + ReactiveInputDelivery::new( + record.clone(), + DeliveryAudience::Owners(vec![HandlerId::new("new-owner")]), + DeliveryScope::OwnerCatchup, + ), + ReactiveInputDelivery::new( + record, + DeliveryAudience::AllExcept(vec![HandlerId::new("new-owner")]), + DeliveryScope::Canonical, + ), + ]); + + let report = runtime + .ingest_batch(&mut cache, batch) + .expect("overlap must merge to one canonical execution"); + let decoded = report + .reports + .iter() + .find_map(|report| match report.as_ref() { + ReactiveReport::Decoded(decoded) => Some(decoded.handler_ids.clone()), + _ => None, + }) + .expect("decoded report"); + + assert_eq!( + decoded, + vec![HandlerId::new("existing"), HandlerId::new("new-owner")] + ); + assert_eq!( + runtime.last_canonical_block().map(|block| block.number), + Some(14) + ); +} + #[test] -fn engine_duplicate_handler_does_not_mutate_subscriber() { +fn lossless_batch_parts_preserve_per_record_delivery_contract() { + let emitter = Address::repeat_byte(0xa5); + let canonical: ReactiveInputRecord = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log(emitter, keccak256(b"Canonical()"), 20)), + included_context(20), + ); + let mut historical_context = included_context(10); + historical_context.source = InputSource::Backfill; + let historical: ReactiveInputRecord = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log(emitter, keccak256(b"Historical()"), 10)), + historical_context, + ); + let progress = BlockRef { + number: 20, + hash: B256::repeat_byte(20), + parent_hash: Some(B256::repeat_byte(19)), + timestamp: Some(1_700_000_020), + }; + let batch = ReactiveInputBatch::from_deliveries([ + ReactiveInputDelivery::new( + canonical, + DeliveryAudience::AllExcept(vec![HandlerId::new("already-covered")]), + DeliveryScope::Canonical, + ), + ReactiveInputDelivery::new( + historical, + DeliveryAudience::Owners(vec![HandlerId::new("new-owner")]), + DeliveryScope::OwnerCatchup, + ), + ]) + .with_delivery_token(SubscriberDeliveryToken::new(b"delivery-20".to_vec())) + .with_subscriber_checkpoint(SubscriberCheckpoint::new(b"cursor-20".to_vec())) + .with_chain_controls([ChainControl::CanonicalProgress(progress)]); + + let parts = batch.into_parts(); + assert_eq!(parts.deliveries.len(), 2); + assert_eq!( + parts.deliveries[0].audience(), + &DeliveryAudience::AllExcept(vec![HandlerId::new("already-covered")]) + ); + assert_eq!(parts.deliveries[0].scope(), DeliveryScope::Canonical); + assert_eq!( + parts.deliveries[1].audience(), + &DeliveryAudience::Owners(vec![HandlerId::new("new-owner")]) + ); + assert_eq!(parts.deliveries[1].scope(), DeliveryScope::OwnerCatchup); + assert_eq!( + parts.delivery_token.as_ref().map(|token| token.as_bytes()), + Some(&b"delivery-20"[..]) + ); + assert_eq!( + parts + .subscriber_checkpoint + .as_ref() + .map(|checkpoint| checkpoint.as_bytes()), + Some(&b"cursor-20"[..]) + ); + assert_eq!( + parts.chain_controls, + vec![ChainControl::CanonicalProgress(progress)] + ); +} + +#[tokio::test] +async fn raw_engine_ingest_rejects_delivery_metadata_it_cannot_commit() { + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + RecordingSubscriber::default(), + ); + let mut cache = setup_cache().await.expect("cache"); + let tokenized = canonical_log_batch(Address::repeat_byte(0xa9), 23) + .with_delivery_token(SubscriberDeliveryToken::new(b"must-ack".to_vec())); + + assert!( + engine.ingest_batch(&mut cache, tokenized).is_err(), + "a convenience helper must not silently discard acknowledgement state" + ); + assert!(engine.runtime().last_canonical_block().is_none()); +} + +#[test] +fn input_ref_round_trips_for_durable_overlap_journals() { + let record: ReactiveInputRecord = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + Address::repeat_byte(0xa6), + keccak256(b"PersistedIdentity()"), + 21, + )), + included_context(21), + ); + let input_ref = record.input_ref(); + let encoded = serde_json::to_vec(&input_ref).expect("serialize stable input identity"); + let decoded: InputRef = + serde_json::from_slice(&encoded).expect("deserialize stable input identity"); + assert_eq!(decoded, input_ref); +} + +#[tokio::test] +async fn owner_catchup_does_not_rewind_global_canonical_state() { + let emitter = Address::repeat_byte(0xa3); + let global_slot = U256::from(1); + let owner_slot = U256::from(2); + let mut cache = setup_cache().await.expect("cache"); + install_mock_erc20(&mut cache, emitter); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig { + journal_depth: 8, + ..ReactiveConfig::default() + }); + runtime + .register_handler(Arc::new(SlotWriter { + id: HandlerId::new("canonical-owner"), + address: emitter, + slot: global_slot, + value: U256::from(101), + })) + .expect("canonical handler"); + + for number in [100, 101] { + runtime + .ingest_batch(&mut cache, canonical_log_batch(emitter, number)) + .expect("canonical batch"); + } + let finalized = runtime + .last_canonical_block() + .expect("canonical head before owner catch-up"); + runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::Safe(finalized), + ChainControl::Finalized(finalized), + ]), + ) + .expect("finality controls"); + + runtime + .register_handler(Arc::new(SlotWriter { + id: HandlerId::new("historical-owner"), + address: emitter, + slot: owner_slot, + value: U256::from(90), + })) + .expect("historical handler"); + let mut historical_context = included_context(101); + historical_context.source = InputSource::Backfill; + let historical = ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log(emitter, keccak256(b"Event()"), 101)), + historical_context, + )]) + .with_audience(DeliveryAudience::Owners(vec![HandlerId::new( + "historical-owner", + )])) + .with_delivery_scope(DeliveryScope::OwnerCatchup); + runtime + .ingest_batch(&mut cache, historical) + .expect("owner catch-up"); + + assert_eq!( + cache.cached_storage_value(emitter, global_slot), + Some(U256::from(101)), + "historical owner replay must not roll back global canonical effects" + ); + assert_eq!( + cache.cached_storage_value(emitter, owner_slot), + Some(U256::from(90)), + "the targeted owner still receives catch-up at the retained head" + ); + assert_eq!(runtime.last_canonical_block(), Some(finalized)); + assert_eq!(runtime.safe_head(), Some(&finalized)); + assert_eq!(runtime.finalized_head(), Some(&finalized)); +} + +#[tokio::test] +async fn engine_duplicate_handler_does_not_mutate_subscriber() { let mut engine = ReactiveEngine::new( evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), RecordingSubscriber::default(), @@ -220,6 +1149,7 @@ fn engine_duplicate_handler_does_not_mutate_subscriber() { "pool-a", Address::repeat_byte(0xa1), ))) + .await .expect("initial registration should succeed"); let err = engine @@ -227,6 +1157,7 @@ fn engine_duplicate_handler_does_not_mutate_subscriber() { "pool-a", Address::repeat_byte(0xb2), ))) + .await .expect_err("duplicate id should fail before subscriber mutation"); assert!(matches!( @@ -245,8 +1176,8 @@ fn engine_duplicate_handler_does_not_mutate_subscriber() { ); } -#[test] -fn engine_rolls_back_runtime_when_subscriber_registration_fails() { +#[tokio::test] +async fn engine_does_not_commit_runtime_when_subscriber_registration_fails() { let mut engine = ReactiveEngine::new( evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), RecordingSubscriber::fail_owner(HandlerId::new("pool-a")), @@ -257,6 +1188,7 @@ fn engine_rolls_back_runtime_when_subscriber_registration_fails() { "pool-a", Address::repeat_byte(0xa1), ))) + .await .expect_err("subscriber failure should fail engine registration"); assert!(matches!( @@ -267,8 +1199,8 @@ fn engine_rolls_back_runtime_when_subscriber_registration_fails() { assert!(engine.subscriber().owners.is_empty()); } -#[test] -fn engine_unregister_handler_updates_subscriber_then_runtime() { +#[tokio::test] +async fn engine_unregister_handler_updates_subscriber_then_runtime() { let mut engine = ReactiveEngine::new( evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), RecordingSubscriber::default(), @@ -278,10 +1210,13 @@ fn engine_unregister_handler_updates_subscriber_then_runtime() { "pool-a", Address::repeat_byte(0xa1), ))) + .await .expect("engine registration should succeed"); let removed = engine .unregister_handler(&HandlerId::new("pool-a")) + .await + .expect("subscriber removal should succeed") .expect("runtime handler should be removed"); assert_eq!(removed.id(), HandlerId::new("pool-a")); @@ -294,19 +1229,24 @@ fn engine_unregister_handler_updates_subscriber_then_runtime() { ); } -#[test] -fn engine_register_handler_with_backfill_records_owner_backfill() { +#[tokio::test] +async fn engine_register_handler_with_backfill_records_owner_backfill() { + let emitter = Address::repeat_byte(0xa1); + let mut cache = setup_cache().await.expect("cache"); let mut engine = ReactiveEngine::new( evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), RecordingSubscriber::default(), ); - let backfill = SubscriberBackfill::range(100, 120); + engine + .ingest_batch(&mut cache, canonical_log_batch(emitter, 100)) + .expect("retain canonical owner replay anchor"); + let retained = included_context(100).block.expect("block context"); + let backfill = SubscriberBackfill::from_canonical_block_through(retained, retained.number) + .expect("exact retained backfill"); engine - .register_handler_with_backfill( - Arc::new(NoopHandler::new("pool-a", Address::repeat_byte(0xa1))), - backfill, - ) + .register_handler_with_backfill(Arc::new(NoopHandler::new("pool-a", emitter)), backfill) + .await .expect("engine registration with backfill should succeed"); assert!(engine.runtime().contains_handler(&HandlerId::new("pool-a"))); @@ -316,8 +1256,35 @@ fn engine_register_handler_with_backfill_records_owner_backfill() { ); } -#[test] -fn engine_sync_handler_interests_bootstraps_owner_per_handler() { +#[tokio::test] +async fn engine_rejects_deep_owner_backfill_before_subscriber_commit() { + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + RecordingSubscriber::default(), + ); + let error = engine + .register_handler_with_backfill( + Arc::new(NoopHandler::new("pool-a", Address::repeat_byte(0xa1))), + SubscriberBackfill::range(100, 120), + ) + .await + .expect_err("deep owner-only history would have no rollback attachment"); + + assert!(matches!( + error, + ReactiveEngineRegisterError::BackfillOutsideJournal { + start_block: 100, + end_block: Some(120), + retained_anchor: None, + } + )); + assert!(!engine.runtime().contains_handler(&HandlerId::new("pool-a"))); + assert!(engine.subscriber().owners.is_empty()); + assert!(engine.subscriber().backfills.is_empty()); +} + +#[tokio::test] +async fn engine_sync_handler_interests_bootstraps_owner_per_handler() { // A runtime pre-populated with handlers (registered directly, not through // the engine) is bootstrapped onto a fresh subscriber via // `sync_handler_interests`: each handler becomes its own owner, and the @@ -336,12 +1303,22 @@ fn engine_sync_handler_interests_bootstraps_owner_per_handler() { ))) .expect("register pool-b on runtime"); - let mut engine = ReactiveEngine::new(runtime, RecordingSubscriber::default()); + let mut subscriber = RecordingSubscriber::default(); + subscriber + .owners + .insert(HandlerId::new("crash-stale"), Vec::new()); + let mut engine = ReactiveEngine::new(runtime, subscriber); engine .sync_handler_interests() + .await .expect("bootstrap sync should succeed"); assert_eq!(engine.subscriber().owners.len(), 2); + assert_eq!( + engine.subscriber().bulk_replacements, + 1, + "bootstrap must commit all owners through one subscriber revision" + ); assert!( engine .subscriber() @@ -354,18 +1331,196 @@ fn engine_sync_handler_interests_bootstraps_owner_per_handler() { .owner_interests(&HandlerId::new("pool-b")) .is_some() ); + assert!( + engine + .subscriber() + .owner_interests(&HandlerId::new("crash-stale")) + .is_none(), + "fresh bootstrap must exact-replace a service owner committed before a runtime crash" + ); // Bootstrap uses the owner-scoped path, not the full-replacement blob. assert!(engine.subscriber().full_replace_interests.is_empty()); // Re-running is idempotent (upsert semantics). engine .sync_handler_interests() + .await .expect("re-sync should succeed"); assert_eq!(engine.subscriber().owners.len(), 2); + assert_eq!(engine.subscriber().bulk_replacements, 2); } -#[test] -fn registry_and_engine_expose_same_interests_after_registration() { +#[tokio::test] +async fn engine_continuity_sync_exact_replaces_stale_owners_after_canonical_head() { + let emitter = Address::repeat_byte(0xa1); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(NoopHandler::new("pool-a", emitter))) + .expect("register runtime owner"); + let mut subscriber = RecordingSubscriber::default(); + subscriber.owners.insert( + HandlerId::new("crash-stale"), + NoopHandler::new("crash-stale", Address::repeat_byte(0xee)).interests(), + ); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + let adopted = included_context(500).block.expect("cold-start baseline"); + cache.set_block(BlockId::from((adopted.hash, Some(true)))); + cache.set_block_context(Some(adopted.number), None); + cache.set_timestamp(adopted.timestamp); + engine + .adopt_canonical_baseline(&cache, ReactiveCanonicalBaseline::new(1, adopted)) + .expect("adopt cold-start snapshot baseline"); + let baseline = engine + .runtime() + .last_canonical_block() + .expect("canonical baseline"); + + engine + .sync_handler_interests_with_backfill() + .await + .expect("continuity sync"); + + assert_eq!(engine.subscriber().bulk_replacements, 1); + assert_eq!(engine.subscriber().owners.len(), 1); + assert!( + engine + .subscriber() + .owners + .contains_key(&HandlerId::new("pool-a")) + ); + assert!( + !engine + .subscriber() + .owners + .contains_key(&HandlerId::new("crash-stale")), + "the runtime registry is the exact restore-time owner topology" + ); + assert_eq!( + engine.subscriber().replacement_backfill, + Some(SubscriberBackfill::after_canonical_block(baseline).expect("C + 1")) + ); + assert_eq!( + engine + .subscriber() + .replacement_backfill + .expect("replacement backfill") + .start_block(), + 501, + "the cache already embodies block C, so catch-up must start at C + 1" + ); +} + +#[tokio::test] +async fn failed_continuity_sync_preserves_the_previous_owner_topology() { + let emitter = Address::repeat_byte(0xa2); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + runtime + .register_handler(Arc::new(NoopHandler::new("pool-fail", emitter))) + .expect("register runtime owner"); + let mut subscriber = RecordingSubscriber::fail_owner(HandlerId::new("pool-fail")); + subscriber.owners.insert( + HandlerId::new("previous"), + NoopHandler::new("previous", Address::repeat_byte(0xef)).interests(), + ); + let mut engine = ReactiveEngine::new(runtime, subscriber); + let mut cache = setup_cache().await.expect("cache"); + engine + .ingest_batch(&mut cache, canonical_log_batch(emitter, 600)) + .expect("establish canonical runtime baseline"); + + engine + .sync_handler_interests_with_backfill() + .await + .expect_err("forced replacement failure"); + + assert_eq!(engine.subscriber().bulk_replacements, 0); + assert_eq!(engine.subscriber().owners.len(), 1); + assert!( + engine + .subscriber() + .owners + .contains_key(&HandlerId::new("previous")), + "failed replacement must leave the old topology authoritative" + ); +} + +#[tokio::test] +async fn cold_start_baseline_requires_matching_chain_and_exact_cache_pin() { + let block = included_context(700).block.expect("baseline block"); + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + RecordingSubscriber::default(), + ); + let mut cache = setup_cache().await.expect("cache"); + + let error = engine + .adopt_canonical_baseline(&cache, ReactiveCanonicalBaseline::new(2, block)) + .expect_err("cross-chain baseline"); + assert!(matches!( + error, + ReactiveEngineError::Baseline(ReactiveBaselineError::CacheChainMismatch { .. }) + )); + + let error = engine + .adopt_canonical_baseline(&cache, ReactiveCanonicalBaseline::new(1, block)) + .expect_err("latest selector is not an exact snapshot pin"); + assert!(matches!( + error, + ReactiveEngineError::Baseline(ReactiveBaselineError::CacheBlockMismatch { .. }) + )); + + cache.set_block(BlockId::from((block.hash, Some(true)))); + cache.set_block_context(Some(block.number), None); + cache.set_timestamp(block.timestamp); + engine + .adopt_canonical_baseline(&cache, ReactiveCanonicalBaseline::new(1, block)) + .expect("exact cold-start baseline"); + engine + .adopt_canonical_baseline(&cache, ReactiveCanonicalBaseline::new(1, block)) + .expect("exact repeat is idempotent"); + + let conflicting = BlockRef { + hash: B256::repeat_byte(0x7f), + ..block + }; + cache.set_block(BlockId::from((conflicting.hash, Some(true)))); + let error = engine + .adopt_canonical_baseline(&cache, ReactiveCanonicalBaseline::new(1, conflicting)) + .expect_err("conflicting repeat"); + assert!(matches!( + error, + ReactiveEngineError::Baseline(ReactiveBaselineError::ConflictingBaseline { .. }) + )); +} + +#[tokio::test] +async fn cold_start_baseline_rejects_a_runtime_that_already_processed_input() { + let emitter = Address::repeat_byte(0xa3); + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + RecordingSubscriber::default(), + ); + let mut cache = setup_cache().await.expect("cache"); + engine + .ingest_batch(&mut cache, canonical_log_batch(emitter, 710)) + .expect("activate runtime"); + + let requested = included_context(711).block.expect("requested baseline"); + cache.set_block(BlockId::from((requested.hash, Some(true)))); + cache.set_block_context(Some(requested.number), None); + cache.set_timestamp(requested.timestamp); + let error = engine + .adopt_canonical_baseline(&cache, ReactiveCanonicalBaseline::new(1, requested)) + .expect_err("active runtime cannot adopt a snapshot baseline"); + assert!(matches!( + error, + ReactiveEngineError::Baseline(ReactiveBaselineError::ActiveRuntime) + )); +} + +#[tokio::test] +async fn registry_and_engine_expose_same_interests_after_registration() { let mut registry = ReactiveRegistry::::new(); registry .register_handler(Arc::new(NoopHandler::new( @@ -383,6 +1538,7 @@ fn registry_and_engine_expose_same_interests_after_registration() { "pool-a", Address::repeat_byte(0xa1), ))) + .await .expect("engine registration should succeed"); assert_eq!( @@ -450,6 +1606,7 @@ async fn engine_register_handler_auto_anchors_from_last_canonical_block() { ); engine .register_handler(Arc::new(NoopHandler::new("seed", emitter))) + .await .expect("register seed handler"); // Drive the runtime's canonical position to block 500. @@ -467,20 +1624,23 @@ async fn engine_register_handler_auto_anchors_from_last_canonical_block() { "pool-late", Address::repeat_byte(0xb2), ))) + .await .expect("register late handler"); assert_eq!( - engine.subscriber().backfills, + engine.subscriber().coordinated_catchups, vec![( HandlerId::new("pool-late"), - SubscriberBackfill::from_block(500) + included_context(500) + .block + .expect("canonical block context") )], - "mid-lifecycle registration must anchor to the last canonical block" + "mid-lifecycle registration must request coordinated C/C+1 catch-up" ); } -#[test] -fn engine_register_handler_on_fresh_runtime_is_live_only() { +#[tokio::test] +async fn engine_register_handler_on_fresh_runtime_is_live_only() { // With no canonical block journaled yet, default registration requests no // backfill (bootstrap before ingestion). let mut engine = ReactiveEngine::new( @@ -492,6 +1652,7 @@ fn engine_register_handler_on_fresh_runtime_is_live_only() { "pool-a", Address::repeat_byte(0xa1), ))) + .await .expect("register on fresh runtime"); assert!( engine.subscriber().backfills.is_empty(), @@ -505,6 +1666,35 @@ fn engine_register_handler_on_fresh_runtime_is_live_only() { ); } +#[tokio::test] +async fn engine_register_handler_is_live_only_when_zero_depth_cannot_attach_owner_history() { + let emitter = Address::repeat_byte(0xa1); + let mut cache = setup_cache().await.expect("cache"); + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig { + journal_depth: 0, + ..ReactiveConfig::default() + }), + RecordingSubscriber::default(), + ); + engine + .ingest_batch(&mut cache, canonical_log_batch(emitter, 500)) + .expect("advance canonical coverage without retaining rollback history"); + engine + .register_handler(Arc::new(NoopHandler::new("pool-a", emitter))) + .await + .expect("fall back to live-only registration"); + + assert!(engine.subscriber().coordinated_catchups.is_empty()); + assert!(engine.subscriber().backfills.is_empty()); + assert!( + engine + .subscriber() + .owner_interests(&HandlerId::new("pool-a")) + .is_some() + ); +} + #[tokio::test] async fn engine_register_handler_live_only_never_backfills() { let emitter = Address::repeat_byte(0xa1); @@ -517,6 +1707,7 @@ async fn engine_register_handler_live_only_never_backfills() { ); engine .register_handler(Arc::new(NoopHandler::new("seed", emitter))) + .await .expect("register seed"); engine .ingest_batch(&mut cache, canonical_log_batch(emitter, 500)) @@ -528,6 +1719,7 @@ async fn engine_register_handler_live_only_never_backfills() { "pool-live", Address::repeat_byte(0xb2), ))) + .await .expect("register live-only"); assert!(engine.subscriber().backfills.is_empty()); assert!( @@ -553,14 +1745,16 @@ async fn engine_next_ingest_with_resync_executes_surfaced_resyncs() { id: HandlerId::new("repair"), address: emitter, })) + .await .expect("register resync handler"); // Queue a batch for the subscriber to hand back, then drive the // resync-executing loop. + let token = SubscriberDeliveryToken::new(vec![0x05]); engine .subscriber_mut() .batches - .push_back(canonical_log_batch(emitter, 700)); + .push_back(canonical_log_batch(emitter, 700).with_delivery_token(token.clone())); let report = engine .next_ingest_with_resync(&mut cache) @@ -578,6 +1772,178 @@ async fn engine_next_ingest_with_resync_executes_surfaced_resyncs() { "resync execution should produce a Resynced report" ); assert!(engine.runtime().pending_resyncs().is_empty()); + assert_eq!(engine.subscriber().acknowledged, vec![token]); +} + +#[tokio::test] +async fn engine_acknowledges_delivery_after_successful_ingest() { + let emitter = Address::repeat_byte(0xd2); + let mut cache = setup_cache().await.expect("cache"); + install_mock_erc20(&mut cache, emitter); + + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + RecordingSubscriber::default(), + ); + let token = SubscriberDeliveryToken::new(vec![0x01, 0x02, 0x03]); + engine + .subscriber_mut() + .batches + .push_back(canonical_log_batch(emitter, 701).with_delivery_token(token.clone())); + + engine + .next_ingest(&mut cache) + .await + .expect("delivery should ingest and acknowledge") + .expect("a batch was queued"); + + assert_eq!(engine.subscriber().acknowledged, vec![token]); +} + +#[tokio::test] +async fn engine_does_not_acknowledge_delivery_when_ingest_fails() { + let emitter = Address::repeat_byte(0xd3); + let slot = U256::from(7); + let mut cache = setup_cache().await.expect("cache"); + let mut runtime = evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()); + for (id, value) in [("first", 1), ("second", 2)] { + runtime + .register_handler(Arc::new(SlotWriter { + id: HandlerId::new(id), + address: emitter, + slot, + value: U256::from(value), + })) + .expect("register conflicting writer"); + } + let mut engine = ReactiveEngine::new(runtime, RecordingSubscriber::default()); + let token = SubscriberDeliveryToken::new(vec![0x04]); + engine + .subscriber_mut() + .batches + .push_back(canonical_log_batch(emitter, 702).with_delivery_token(token)); + + let error = engine + .next_ingest(&mut cache) + .await + .expect_err("conflicting effects must fail ingestion"); + + assert!(matches!(error, ReactiveEngineError::Runtime(_))); + assert!(engine.subscriber().acknowledged.is_empty()); +} + +#[tokio::test] +async fn engine_distinguishes_acknowledgement_failure_after_ingest() { + let emitter = Address::repeat_byte(0xd4); + let mut cache = setup_cache().await.expect("cache"); + let subscriber = RecordingSubscriber { + fail_acknowledgement: true, + ..Default::default() + }; + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + let failed_token = SubscriberDeliveryToken::new(vec![0x06]); + let later_token = SubscriberDeliveryToken::new(vec![0x07]); + engine.subscriber_mut().batches.extend([ + canonical_log_batch(emitter, 703).with_delivery_token(failed_token.clone()), + canonical_log_batch(emitter, 704).with_delivery_token(later_token.clone()), + ]); + + let error = engine + .next_ingest(&mut cache) + .await + .expect_err("acknowledgement failure should surface after ingestion"); + + assert!(matches!(error, ReactiveEngineError::Acknowledgement(_))); + assert_eq!( + engine + .runtime() + .last_canonical_block() + .expect("batch was ingested before acknowledgement") + .number, + 703 + ); + assert!(engine.subscriber().acknowledged.is_empty()); + assert_eq!(engine.subscriber().batches.len(), 1); + + engine.subscriber_mut().fail_acknowledgement = false; + engine + .next_ingest(&mut cache) + .await + .expect("pending acknowledgement retries") + .expect("the already-ingested report is returned after acknowledgement"); + assert_eq!(engine.subscriber().acknowledged, vec![failed_token]); + assert_eq!( + engine.subscriber().batches.len(), + 1, + "the retry must not poll a later delivery" + ); + assert_eq!( + engine + .runtime() + .last_canonical_block() + .expect("later delivery has not been polled") + .number, + 703 + ); + + engine + .next_ingest(&mut cache) + .await + .expect("later delivery") + .expect("later batch remains queued"); + assert_eq!( + engine.subscriber().acknowledged, + vec![SubscriberDeliveryToken::new(vec![0x06]), later_token] + ); +} + +#[tokio::test] +async fn cancelled_acknowledgement_is_retried_before_polling_a_later_batch() { + let emitter = Address::repeat_byte(0xd5); + let mut cache = setup_cache().await.expect("cache"); + let subscriber = RecordingSubscriber { + hold_acknowledgement: true, + ..Default::default() + }; + let mut engine = ReactiveEngine::new( + evm_fork_cache::ReactiveRuntime::::new(ReactiveConfig::default()), + subscriber, + ); + let pending_token = SubscriberDeliveryToken::new(vec![0x08]); + let later_token = SubscriberDeliveryToken::new(vec![0x09]); + engine.subscriber_mut().batches.extend([ + canonical_log_batch(emitter, 705).with_delivery_token(pending_token.clone()), + canonical_log_batch(emitter, 706).with_delivery_token(later_token), + ]); + + let mut interrupted = Box::pin(engine.next_ingest(&mut cache)); + assert!( + futures::poll!(&mut interrupted).is_pending(), + "the subscriber holds the acknowledgement open" + ); + drop(interrupted); + assert_eq!(engine.subscriber().batches.len(), 1); + assert_eq!( + engine + .runtime() + .last_canonical_block() + .expect("delivery was applied before the cancelled acknowledgement") + .number, + 705 + ); + + engine.subscriber_mut().hold_acknowledgement = false; + engine + .next_ingest(&mut cache) + .await + .expect("cancelled acknowledgement retries") + .expect("stored report returns"); + assert_eq!(engine.subscriber().acknowledged, vec![pending_token]); + assert_eq!(engine.subscriber().batches.len(), 1); + assert_eq!(engine.runtime().last_canonical_block().unwrap().number, 705); } #[tokio::test] @@ -597,6 +1963,7 @@ async fn engine_teardown_recipe_clears_routing_tracking_and_pending_resyncs() { id: HandlerId::new("pool"), address: emitter, })) + .await .expect("register"); engine .runtime_mut() @@ -609,7 +1976,10 @@ async fn engine_teardown_recipe_clears_routing_tracking_and_pending_resyncs() { assert_eq!(engine.runtime().pending_resyncs().len(), 1); // Full teardown recipe. - let removed = engine.unregister_handler(&HandlerId::new("pool")); + let removed = engine + .unregister_handler(&HandlerId::new("pool")) + .await + .expect("subscriber removal should succeed"); assert!(removed.is_some()); assert!(engine.runtime_mut().untrack_account(emitter)); let cancelled = engine.runtime_mut().cancel_pending_resyncs(emitter); diff --git a/tests/reactive_flashblocks.rs b/tests/reactive_flashblocks.rs new file mode 100644 index 0000000..e465c4c --- /dev/null +++ b/tests/reactive_flashblocks.rs @@ -0,0 +1,206 @@ +#![cfg(feature = "reactive")] + +mod common; + +use std::sync::Arc; + +use alloy_network::Ethereum; +use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256}; +use alloy_rpc_types_eth::{Filter, Log}; +use anyhow::Result; +use common::{install_mock_erc20, setup_cache}; +use evm_fork_cache::reactive::{ + BaseFlashblockPayload, BlockRef, ChainStatus, DeliveryScope, FlashblockRef, HandlerError, + HandlerId, HandlerOutcome, InputSource, LogInterest, PreconfirmationMode, ProviderRef, + ReactiveConfig, ReactiveContext, ReactiveEffect, ReactiveHandler, ReactiveInput, + ReactiveInputBatch, ReactiveInputRecord, ReactiveInterest, ReactiveRuntime, StateEffectQuality, + SubscriberConfig, +}; +use evm_fork_cache::{StateUpdate, events::StateView}; + +fn flashblock(provider: ProviderRef, index: u64, hash: B256) -> FlashblockRef { + FlashblockRef { + provider, + payload_id: Some([0x11; 8].into()), + index: Some(index), + block_number: 101, + block_hash: hash, + parent_hash: Some(B256::repeat_byte(0x64)), + state_root: Some(B256::repeat_byte(0xaa)), + timestamp: Some(1_700_000_101), + } +} + +fn rpc_log(address: Address, block: BlockRef, tx: u8) -> Log { + Log { + inner: PrimitiveLog::new_unchecked(address, Vec::new(), Bytes::new()), + block_hash: Some(block.hash), + block_number: Some(block.number), + block_timestamp: block.timestamp, + transaction_hash: Some(B256::repeat_byte(tx)), + transaction_index: Some(0), + log_index: Some(0), + removed: false, + } +} + +fn preconfirmed_record(address: Address, flashblock: FlashblockRef) -> ReactiveInputRecord { + let block = flashblock.block_ref(); + let provider = flashblock.provider.clone(); + ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log(address, block, 0x41)), + ReactiveContext { + chain_id: Some(1), + source: InputSource::Flashblocks, + chain_status: ChainStatus::Preconfirmed { flashblock }, + block: Some(block), + transaction_index: Some(0), + log_index: Some(0), + }, + ) + .with_provider(provider) +} + +fn canonical_record(address: Address) -> ReactiveInputRecord { + let block = BlockRef { + number: 101, + hash: B256::repeat_byte(0xbb), + parent_hash: Some(B256::repeat_byte(0x64)), + timestamp: Some(1_700_000_101), + }; + ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log(address, block, 0x42)), + ReactiveContext { + chain_id: Some(1), + source: InputSource::Subscription, + chain_status: ChainStatus::Included { + block, + confirmations: 0, + }, + block: Some(block), + transaction_index: Some(0), + log_index: Some(0), + }, + ) +} + +struct SlotWriter { + address: Address, + slot: U256, + value: U256, +} + +impl ReactiveHandler for SlotWriter { + fn id(&self) -> HandlerId { + HandlerId::new("flashblock-slot-writer") + } + + fn interests(&self) -> Vec { + vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(self.address), + local_matcher: None, + route_key: None, + })] + } + + fn handle( + &self, + _ctx: &ReactiveContext, + _input: &ReactiveInput, + _state: &dyn StateView, + ) -> std::result::Result { + Ok(HandlerOutcome { + effects: vec![ReactiveEffect::StateUpdate(StateUpdate::slot( + self.address, + self.slot, + self.value, + ))], + quality: StateEffectQuality::ExactFromInput, + tags: Vec::new(), + }) + } +} + +#[test] +fn base_flashblock_wire_decodes_decimal_index_and_hex_header_quantities() -> Result<()> { + let payload: BaseFlashblockPayload = serde_json::from_str( + r#"{ + "payload_id":"0x1111111111111111", + "index":4, + "base":{ + "parent_hash":"0x6464646464646464646464646464646464646464646464646464646464646464", + "block_number":"0x65", + "timestamp":"0x6553f165" + }, + "diff":{ + "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "metadata":{"block_number":101} + }"#, + )?; + assert_eq!(payload.index, 4); + assert_eq!(payload.base.expect("index-zero header").block_number, 101); + assert_eq!(payload.metadata.expect("metadata").block_number, 101); + Ok(()) +} + +#[test] +fn flashblocks_policy_is_disabled_by_default_and_op_polling_is_explicit() { + let default = SubscriberConfig::default(); + assert_eq!(default.preconfirmations, PreconfirmationMode::Disabled); + assert_eq!(default.flashblock_poll_interval.as_millis(), 100); +} + +#[tokio::test] +async fn preconfirmed_updates_are_visible_then_discarded_before_canonical_ingest() -> Result<()> { + let address = Address::repeat_byte(0x77); + let unrelated = Address::repeat_byte(0x88); + let slot = U256::from(7); + let canonical_value = U256::from(1); + let speculative_value = U256::from(99); + let provider = ProviderRef::new("base-flashblocks", 3); + let flashblock = flashblock(provider.clone(), 2, B256::repeat_byte(0xfa)); + + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, address); + cache + .db_mut() + .insert_account_storage(address, slot, canonical_value)?; + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(SlotWriter { + address, + slot, + value: speculative_value, + }))?; + + let record = preconfirmed_record(address, flashblock.clone()); + assert_eq!(record.provider.as_ref(), Some(&provider)); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![record]).with_delivery_scope(DeliveryScope::Preconfirmed), + )?; + + assert_eq!( + cache.cached_storage_value(address, slot), + Some(speculative_value) + ); + assert_eq!(runtime.active_preconfirmation(), Some(&flashblock)); + assert!(runtime.last_canonical_block().is_none()); + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![canonical_record(unrelated)]), + )?; + assert_eq!( + cache.cached_storage_value(address, slot), + Some(canonical_value) + ); + assert!(runtime.active_preconfirmation().is_none()); + assert_eq!( + runtime.last_canonical_block().map(|block| block.number), + Some(101) + ); + Ok(()) +} diff --git a/tests/reactive_freshness.rs b/tests/reactive_freshness.rs index df4d9d0..f9d5003 100644 --- a/tests/reactive_freshness.rs +++ b/tests/reactive_freshness.rs @@ -1,4 +1,4 @@ -//! Manager-authored red-green acceptance test for Phase-8 step 3: `Validity` +//! Red-green acceptance test for Phase-8 step 3: `Validity` //! stamping of reactive/event-derived writes. //! //! With freshness stamping enabled, applying a canonical event write stamps the @@ -23,6 +23,7 @@ use anyhow::Result; use common::setup_cache; use evm_fork_cache::StateUpdate; use evm_fork_cache::events::StateView; +use evm_fork_cache::freshness::Validity; use evm_fork_cache::reactive::{ BlockRef, ChainStatus, HandlerError, HandlerId, HandlerOutcome, InputSource, LogInterest, ReactiveConfig, ReactiveContext, ReactiveEffect, ReactiveHandler, ReactiveInput, @@ -57,7 +58,7 @@ fn included_context(block: BlockRef, log_index: u64) -> ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -66,6 +67,19 @@ fn included_context(block: BlockRef, log_index: u64) -> ReactiveContext { } } +fn reorged_context(block: BlockRef, log_index: u64) -> ReactiveContext { + ReactiveContext { + chain_id: Some(1), + source: InputSource::Batch, + chain_status: ChainStatus::Reorged { + dropped_from: block, + }, + block: Some(block), + transaction_index: Some(0), + log_index: Some(log_index), + } +} + fn batch(input: ReactiveInput, ctx: ReactiveContext) -> ReactiveInputBatch { ReactiveInputBatch::new(vec![ReactiveInputRecord::new(input, ctx)]) } @@ -164,7 +178,7 @@ async fn reactive_write_stamps_validity_through_block() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b10, 10)), - included_context(b10.clone(), 10), + included_context(b10, 10), ), )?; @@ -184,6 +198,43 @@ async fn reactive_write_stamps_validity_through_block() -> Result<()> { Ok(()) } +#[tokio::test] +async fn reorg_invalidates_freshness_stamps_from_the_dropped_branch() -> Result<()> { + let address = Address::repeat_byte(0xf4); + let slot = U256::from(13); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.enable_freshness_stamping(); + runtime.register_handler(Arc::new(BlockValueWriter { address, slot }))?; + let dropped = block(20, B256::repeat_byte(0x20), B256::repeat_byte(0x19)); + + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log(address, &dropped, 0)), + included_context(dropped, 0), + ), + )?; + assert_eq!( + runtime.freshness().unwrap().validity(address, slot), + Validity::ValidThrough(20) + ); + + let mut removed = rpc_log(address, &dropped, 0); + removed.removed = true; + runtime.ingest_batch( + &mut cache, + batch(ReactiveInput::Log(removed), reorged_context(dropped, 0)), + )?; + + assert_eq!( + runtime.freshness().unwrap().validity(address, slot), + Validity::Volatile, + "a dropped-branch stamp cannot certify replacement-branch state" + ); + Ok(()) +} + /// Phase-8 s3: stamping is opt-in — a runtime that never enables it exposes no /// registry (behavior unchanged from before the coupling). #[tokio::test] @@ -233,7 +284,7 @@ async fn pending_write_does_not_stamp_validity() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b10, 10)), - pending_context(b10.clone(), 10), + pending_context(b10, 10), ), ); @@ -272,7 +323,7 @@ async fn later_canonical_stamp_wins() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b10, 10)), - included_context(b10.clone(), 10), + included_context(b10, 10), ), )?; assert!( @@ -292,7 +343,7 @@ async fn later_canonical_stamp_wins() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b11, 11)), - included_context(b11.clone(), 11), + included_context(b11, 11), ), )?; diff --git a/tests/reactive_health.rs b/tests/reactive_health.rs index 0980e78..56735aa 100644 --- a/tests/reactive_health.rs +++ b/tests/reactive_health.rs @@ -1,4 +1,4 @@ -//! Manager-authored red-green acceptance tests for WS-4/WS-5: the queryable +//! Red-green acceptance tests for WS-4/WS-5: the queryable //! `CacheHealth` state and `CacheMetrics` counters on `ReactiveRuntime`. //! //! These describe the public contract before the implementation exists: @@ -59,7 +59,7 @@ fn included_context(block: BlockRef, log_index: u64) -> ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -145,14 +145,14 @@ async fn deep_reorg_beyond_journal_degrades_health() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b10, 10)), - included_context(b10.clone(), 10), + included_context(b10, 10), ), )?; runtime.ingest_batch( &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b11, 11)), - included_context(b11.clone(), 11), + included_context(b11, 11), ), )?; assert_eq!(runtime.health(), CacheHealth::Healthy, "healthy so far"); @@ -163,7 +163,7 @@ async fn deep_reorg_beyond_journal_degrades_health() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b11_alt, 12)), - included_context(b11_alt.clone(), 12), + included_context(b11_alt, 12), ), )?; @@ -206,21 +206,21 @@ async fn in_journal_reorg_recovers_without_degrading() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &parent, 10)), - included_context(parent.clone(), 10), + included_context(parent, 10), ), )?; runtime.ingest_batch( &mut cache, batch( ReactiveInput::Log(rpc_log(address, &dropped, 20)), - included_context(dropped.clone(), 20), + included_context(dropped, 20), ), )?; let report = runtime.ingest_batch( &mut cache, batch( ReactiveInput::Log(rpc_log(address, &replacement, 30)), - included_context(replacement.clone(), 30), + included_context(replacement, 30), ), )?; @@ -318,7 +318,7 @@ async fn resync_requests_and_failures_increment() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b5, 5)), - included_context(b5.clone(), 5), + included_context(b5, 5), ), )?; @@ -418,7 +418,7 @@ async fn metrics_snapshot_starts_all_zero() -> Result<()> { Ok(()) } -/// WS-4 (manager-authored red-green): a forward gap in the canonical block +/// WS-4 red-green coverage: a forward gap in the canonical block /// sequence (block N followed by N+k, k>1) is no longer silently accepted. The /// runtime emits a `ReactiveReport::MissedBlockRange { from, to }` for the skipped /// span, increments `missed_ranges`, and degrades health — while STILL accepting @@ -440,7 +440,7 @@ async fn forward_block_gap_is_detected_and_degrades() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b10, 10)), - included_context(b10.clone(), 10), + included_context(b10, 10), ), )?; assert_eq!(runtime.health(), CacheHealth::Healthy); @@ -449,7 +449,7 @@ async fn forward_block_gap_is_detected_and_degrades() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b15, 15)), - included_context(b15.clone(), 15), + included_context(b15, 15), ), )?; @@ -480,7 +480,7 @@ async fn forward_block_gap_is_detected_and_degrades() -> Result<()> { Ok(()) } -/// WS-4 (manager-authored red-green): repeated trust-loss events escalate the +/// WS-4 red-green coverage: repeated trust-loss events escalate the /// health state — the first degrades to `Degraded`, a second (here a second gap) /// escalates to `Unhealthy` (the "stop until rebuilt" signal). #[tokio::test] @@ -501,7 +501,7 @@ async fn repeated_trust_loss_escalates_to_unhealthy() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, b, b.number)), - included_context(b.clone(), b.number), + included_context(*b, b.number), ), )?; } @@ -516,7 +516,7 @@ async fn repeated_trust_loss_escalates_to_unhealthy() -> Result<()> { Ok(()) } -/// WS-4 (manager-authored red-green): after the caller has repaired/resynced, +/// WS-4 red-green coverage: after the caller has repaired/resynced, /// `reset_health` returns the runtime to `Healthy` (the self-heal completion). #[tokio::test] async fn reset_health_restores_healthy() -> Result<()> { @@ -534,7 +534,7 @@ async fn reset_health_restores_healthy() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, b, b.number)), - included_context(b.clone(), b.number), + included_context(*b, b.number), ), )?; } @@ -545,7 +545,7 @@ async fn reset_health_restores_healthy() -> Result<()> { Ok(()) } -/// WS-4 (implementation agent): mixed trust-loss event types share the same +/// WS-4: mixed trust-loss event types share the same /// escalation ladder. A deep reorg (journal_depth=1, parent aged out) degrades to /// `Degraded`, then a subsequent forward gap escalates to `Unhealthy`. #[tokio::test] @@ -573,7 +573,7 @@ async fn mixed_trust_loss_events_escalate_to_unhealthy() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, b, log_index)), - included_context(b.clone(), log_index), + included_context(*b, log_index), ), )?; } @@ -584,7 +584,7 @@ async fn mixed_trust_loss_events_escalate_to_unhealthy() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b11_alt, 12)), - included_context(b11_alt.clone(), 12), + included_context(b11_alt, 12), ), )?; assert!( @@ -598,7 +598,7 @@ async fn mixed_trust_loss_events_escalate_to_unhealthy() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b16, 16)), - included_context(b16.clone(), 16), + included_context(b16, 16), ), )?; assert!( @@ -611,7 +611,7 @@ async fn mixed_trust_loss_events_escalate_to_unhealthy() -> Result<()> { Ok(()) } -/// WS-4 (implementation agent): the `MissedBlockRange` report's `block` field +/// WS-4: the `MissedBlockRange` report's `block` field /// equals the arriving block number that revealed the gap. #[tokio::test] async fn missed_range_report_block_equals_arriving_block() -> Result<()> { @@ -629,7 +629,7 @@ async fn missed_range_report_block_equals_arriving_block() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b10, 10)), - included_context(b10.clone(), 10), + included_context(b10, 10), ), )?; @@ -637,7 +637,7 @@ async fn missed_range_report_block_equals_arriving_block() -> Result<()> { &mut cache, batch( ReactiveInput::Log(rpc_log(address, &b15, 15)), - included_context(b15.clone(), 15), + included_context(b15, 15), ), )?; diff --git a/tests/reactive_registry.proptest-regressions b/tests/reactive_registry.proptest-regressions new file mode 100644 index 0000000..e2061ab --- /dev/null +++ b/tests/reactive_registry.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 4ed95af83269406e10e1edf30a9101aa74ae4806ac4d40b6e470313492c441bb # shrinks to shapes = [(None, None, Some([0]), None, None), (None, None, None, None, Some([0]))], logs = [(0, 0, 1, 0, 1)] diff --git a/tests/reactive_registry.rs b/tests/reactive_registry.rs index 72ef480..b6c6939 100644 --- a/tests/reactive_registry.rs +++ b/tests/reactive_registry.rs @@ -7,8 +7,9 @@ use std::sync::{ }; use alloy_network::Ethereum; -use alloy_primitives::Address; +use alloy_primitives::{Address, B256}; use alloy_rpc_types_eth::Filter; +use proptest::prelude::*; use evm_fork_cache::events::StateView; use evm_fork_cache::reactive::{ @@ -22,6 +23,40 @@ struct NoopHandler { address: Address, } +struct FilterSetHandler { + id: HandlerId, + filters: Vec, +} + +impl ReactiveHandler for FilterSetHandler { + fn id(&self) -> HandlerId { + self.id.clone() + } + + fn interests(&self) -> Vec { + self.filters + .iter() + .cloned() + .map(|provider_filter| { + ReactiveInterest::Logs(LogInterest { + provider_filter, + local_matcher: None, + route_key: None, + }) + }) + .collect() + } + + fn handle( + &self, + _ctx: &ReactiveContext, + _input: &ReactiveInput, + _state: &dyn StateView, + ) -> Result { + Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect)) + } +} + impl NoopHandler { fn new(id: impl Into, address: Address) -> Self { Self { @@ -340,6 +375,21 @@ fn reactive_registry_rejects_duplicate_handler_ids() { )); } +#[test] +fn handler_ids_reject_the_protocol_reserved_empty_identity() { + assert!(HandlerId::try_new("").is_err()); + assert!( + serde_json::from_str::(r#"""#).is_err(), + "deserialization must not bypass the public constructor invariant" + ); + assert_eq!( + serde_json::from_str::(r#""pool-a""#) + .expect("non-empty handler id") + .as_str(), + "pool-a" + ); +} + #[test] fn reactive_registry_unregisters_one_handler_without_rebuilding_others() { let pool_a = Address::repeat_byte(0xa1); @@ -455,3 +505,240 @@ fn reactive_registry_handler_ids_preserve_registration_order() { vec![HandlerId::new("first"), HandlerId::new("third")] ); } + +fn planned_filters(filters: Vec) -> Vec { + let mut registry = ReactiveRegistry::::new(); + registry + .register_handler(Arc::new(FilterSetHandler { + id: HandlerId::new("filter-planner-regression"), + filters, + })) + .expect("register filter planner fixture"); + registry.log_subscription_filters() +} + +fn shaped_log( + address: Address, + topics: impl IntoIterator, +) -> alloy_rpc_types_eth::Log { + alloy_rpc_types_eth::Log { + inner: alloy_primitives::Log::new_unchecked( + address, + topics.into_iter().collect(), + alloy_primitives::Bytes::new(), + ), + block_hash: Some(B256::repeat_byte(0x10)), + block_number: Some(10), + block_timestamp: Some(1_700_000_010), + transaction_hash: Some(B256::repeat_byte(0x20)), + transaction_index: Some(0), + log_index: Some(0), + removed: false, + } +} + +#[test] +fn yearn_filter_shapes_never_plan_a_global_transfer_subscription() { + let transfer = B256::repeat_byte(0x01); + let factory_registered = B256::repeat_byte(0x02); + let auction_kicked = B256::repeat_byte(0x03); + let auction = Address::repeat_byte(0xa1); + let want = Address::repeat_byte(0xb2); + let receiver = Address::repeat_byte(0xc3); + let unrelated_token = Address::repeat_byte(0xd4); + let unrelated_sender = B256::repeat_byte(0xe5); + let unrelated_receiver = B256::repeat_byte(0xf6); + let auction_topic = B256::left_padding_from(auction.as_slice()); + let receiver_topic = B256::left_padding_from(receiver.as_slice()); + + let logical = vec![ + Filter::new().event_signature(vec![factory_registered, auction_kicked]), + Filter::new() + .event_signature(transfer) + .topic1(auction_topic), + Filter::new() + .address(want) + .event_signature(transfer) + .topic2(receiver_topic), + ]; + let planned = planned_filters(logical.clone()); + let unrelated_transfer = shaped_log( + unrelated_token, + [transfer, unrelated_sender, unrelated_receiver], + ); + let representative_logs = [ + shaped_log(Address::repeat_byte(0x99), [factory_registered]), + shaped_log( + unrelated_token, + [transfer, auction_topic, unrelated_receiver], + ), + shaped_log(want, [transfer, unrelated_sender, receiver_topic]), + ]; + + assert_eq!( + planned.len(), + 3, + "two-dimensional differences must stay separate" + ); + for representative in representative_logs { + assert!( + logical + .iter() + .any(|filter| filter.rpc_matches(&representative)) + ); + assert!( + planned + .iter() + .any(|filter| filter.rpc_matches(&representative)) + ); + } + assert!( + !logical + .iter() + .any(|filter| filter.rpc_matches(&unrelated_transfer)) + ); + assert!( + !planned + .iter() + .any(|filter| filter.rpc_matches(&unrelated_transfer)) + ); +} + +#[test] +fn filters_that_differ_in_address_and_topic_do_not_gain_cross_product_matches() { + let address_a = Address::repeat_byte(0xa1); + let address_b = Address::repeat_byte(0xb2); + let topic_a = B256::repeat_byte(0x11); + let topic_b = B256::repeat_byte(0x22); + let logical = vec![ + Filter::new().address(address_a).event_signature(topic_a), + Filter::new().address(address_b).event_signature(topic_b), + ]; + let planned = planned_filters(logical); + + assert_eq!(planned.len(), 2); + assert!( + !planned + .iter() + .any(|filter| filter.rpc_matches(&shaped_log(address_a, [topic_b]))) + ); + assert!( + !planned + .iter() + .any(|filter| filter.rpc_matches(&shaped_log(address_b, [topic_a]))) + ); +} + +#[test] +fn wildcard_and_constrained_dimensions_merge_only_by_subsumption() { + let address = Address::repeat_byte(0xa1); + let other = Address::repeat_byte(0xb2); + let topic = B256::repeat_byte(0x11); + let other_topic = B256::repeat_byte(0x22); + + let address_wildcard = planned_filters(vec![ + Filter::new().event_signature(topic), + Filter::new().address(address).event_signature(topic), + ]); + assert_eq!(address_wildcard.len(), 1); + assert!(address_wildcard[0].rpc_matches(&shaped_log(other, [topic]))); + assert!(!address_wildcard[0].rpc_matches(&shaped_log(other, [other_topic]))); + + let topic_wildcard = planned_filters(vec![ + Filter::new().address(address), + Filter::new().address(address).event_signature(topic), + ]); + assert_eq!(topic_wildcard.len(), 1); + assert!(topic_wildcard[0].rpc_matches(&shaped_log(address, [other_topic]))); + assert!(!topic_wildcard[0].rpc_matches(&shaped_log(other, [other_topic]))); +} + +fn filter_dimension() -> impl Strategy>> { + prop_oneof![ + Just(None), + prop::collection::vec(0_u8..4, 1..4).prop_map(Some), + ] +} + +type FilterShape = ( + Option>, + Option>, + Option>, + Option>, + Option>, +); + +fn filter_shape() -> impl Strategy { + ( + filter_dimension(), + filter_dimension(), + filter_dimension(), + filter_dimension(), + filter_dimension(), + ) +} + +fn filter_from_shape((addresses, topic0, topic1, topic2, topic3): FilterShape) -> Filter { + let mut filter = Filter::new(); + if let Some(values) = addresses { + filter = filter.address( + values + .into_iter() + .map(Address::repeat_byte) + .collect::>(), + ); + } + if let Some(values) = topic0 { + filter = filter.event_signature( + values + .into_iter() + .map(B256::repeat_byte) + .collect::>(), + ); + } + if let Some(values) = topic1 { + filter = filter.topic1( + values + .into_iter() + .map(B256::repeat_byte) + .collect::>(), + ); + } + if let Some(values) = topic2 { + filter = filter.topic2( + values + .into_iter() + .map(B256::repeat_byte) + .collect::>(), + ); + } + if let Some(values) = topic3 { + filter = filter.topic3( + values + .into_iter() + .map(B256::repeat_byte) + .collect::>(), + ); + } + filter +} + +proptest! { + #[test] + fn planned_filter_union_has_no_false_positives_or_false_negatives( + shapes in prop::collection::vec(filter_shape(), 1..12), + logs in prop::collection::vec((0_u8..4, 0_u8..4, 0_u8..4, 0_u8..4, 0_u8..4), 1..64), + ) { + let logical = shapes.into_iter().map(filter_from_shape).collect::>(); + let planned = planned_filters(logical.clone()); + for (address, topic0, topic1, topic2, topic3) in logs { + let log = shaped_log( + Address::repeat_byte(address), + [topic0, topic1, topic2, topic3].map(B256::repeat_byte), + ); + let expected = logical.iter().any(|filter| filter.rpc_matches(&log)); + let actual = planned.iter().any(|filter| filter.rpc_matches(&log)); + prop_assert_eq!(actual, expected); + } + } +} diff --git a/tests/reactive_reorg.rs b/tests/reactive_reorg.rs index 47326bf..01d18f8 100644 --- a/tests/reactive_reorg.rs +++ b/tests/reactive_reorg.rs @@ -1,4 +1,4 @@ -//! Manager-authored acceptance tests for reactive block journaling and reorg recovery. +//! Acceptance tests for reactive block journaling and reorg recovery. //! //! These tests cover the runtime-owned machinery that downstream crates should not //! need to rebuild: journaling canonical block effects, handling removed/reorged @@ -10,6 +10,7 @@ mod common; use std::sync::Arc; +use alloy_eips::BlockId; use alloy_network::Ethereum; use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256, keccak256}; use alloy_rpc_types_eth::{Filter, Log}; @@ -18,11 +19,16 @@ use anyhow::Result; use common::{install_mock_erc20, setup_cache}; use evm_fork_cache::events::StateView; use evm_fork_cache::reactive::{ - BlockRef, ChainStatus, HandlerError, HandlerId, HandlerOutcome, InputSource, - InvalidationReason, InvalidationRequest, LogInterest, ReactiveConfig, ReactiveContext, - ReactiveEffect, ReactiveHandler, ReactiveInput, ReactiveInputBatch, ReactiveInputRecord, - ReactiveInterest, ReactiveReport, ReactiveRuntime, ResyncBlock, ResyncId, ResyncPriority, - ResyncReason, ResyncRequest, ResyncTarget, RouteKeySpec, StateEffectQuality, + BlockRef, CanonicalRollbackKind, CanonicalSequenceError, CanonicalSequenceMutation, + CanonicalSequenceState, ChainControl, ChainStatus, DeliveryAudience, DeliveryScope, + HandlerError, HandlerId, HandlerOutcome, InputRef, InputSource, InvalidationReason, + InvalidationRequest, LogInterest, ReactiveConfig, ReactiveContext, ReactiveEffect, + ReactiveError, ReactiveHandler, ReactiveInput, ReactiveInputBatch, ReactiveInputDelivery, + ReactiveInputIdentity, ReactiveInputKind, ReactiveInputRecord, ReactiveInterest, + ReactiveReport, ReactiveRuntime, ResyncBlock, ResyncId, ResyncPriority, ResyncReason, + ResyncRequest, ResyncTarget, RouteKeySpec, StateEffectQuality, + normalize_and_validate_canonical_sequence, validate_canonical_sequence, + validate_canonical_sequence_diagnostic, }; use evm_fork_cache::{PurgeScope, StateUpdate}; @@ -60,7 +66,7 @@ fn included_context(block: BlockRef, log_index: u64) -> ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -73,17 +79,2655 @@ fn reorged_context(dropped_from: BlockRef, log_index: u64) -> ReactiveContext { ReactiveContext { chain_id: Some(1), source: InputSource::Batch, - chain_status: ChainStatus::Reorged { - dropped_from: dropped_from.clone(), - }, + chain_status: ChainStatus::Reorged { dropped_from }, block: Some(dropped_from), transaction_index: Some(0), log_index: Some(log_index), } } -fn batch(input: ReactiveInput, ctx: ReactiveContext) -> ReactiveInputBatch { - ReactiveInputBatch::new(vec![ReactiveInputRecord::new(input, ctx)]) +fn batch(input: ReactiveInput, ctx: ReactiveContext) -> ReactiveInputBatch { + ReactiveInputBatch::new(vec![ReactiveInputRecord::new(input, ctx)]) +} + +fn sequence_log_record( + block: BlockRef, + log_index: u64, + removed: bool, +) -> ReactiveInputRecord { + ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + Address::repeat_byte(0xce), + vec![keccak256(b"CanonicalSequence()")], + &block, + 0, + log_index, + removed, + )), + if removed { + reorged_context(block, log_index) + } else { + included_context(block, log_index) + }, + ) +} + +fn sequence_log_record_with_context_block( + payload_block: BlockRef, + context_block: BlockRef, + log_index: u64, + removed: bool, +) -> ReactiveInputRecord { + ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + Address::repeat_byte(0xce), + vec![keccak256(b"CanonicalSequence()")], + &payload_block, + 0, + log_index, + removed, + )), + if removed { + reorged_context(context_block, log_index) + } else { + included_context(context_block, log_index) + }, + ) +} + +fn replay_sequence_mutations( + initial: &CanonicalSequenceState, + mutations: &[CanonicalSequenceMutation], +) -> CanonicalSequenceState { + let mut history = initial.retained_canonical_history().to_vec(); + let mut coverage = initial.coverage_head().copied(); + let mut safe = initial.safe_head().copied(); + let mut finalized = initial.finalized_head().copied(); + let enrich = |current: &mut BlockRef, incoming: &BlockRef| { + current.parent_hash = current.parent_hash.or(incoming.parent_hash); + current.timestamp = current.timestamp.or(incoming.timestamp); + }; + let clear_above = |head: &mut Option, ancestor: Option| { + if head.is_some_and(|head| { + ancestor.is_none_or(|ancestor| { + head.number > ancestor.number + || (head.number == ancestor.number && head.hash != ancestor.hash) + }) + }) { + *head = None; + } + }; + + for mutation in mutations { + match mutation { + CanonicalSequenceMutation::Rewind { + common_ancestor, + dropped, + } => { + history.retain(|block| { + !dropped + .iter() + .any(|dropped| block.number == dropped.number && block.hash == dropped.hash) + }); + coverage = *common_ancestor; + clear_above(&mut safe, *common_ancestor); + clear_above(&mut finalized, *common_ancestor); + } + CanonicalSequenceMutation::Canonical(block) => { + if let Some(existing) = history + .iter_mut() + .find(|entry| entry.number == block.number && entry.hash == block.hash) + { + enrich(existing, block); + } else { + history.push(*block); + history.sort_by_key(|entry| entry.number); + } + match coverage.as_mut() { + Some(current) + if current.number == block.number && current.hash == block.hash => + { + enrich(current, block); + } + Some(current) if current.number >= block.number => {} + _ => coverage = Some(*block), + } + } + CanonicalSequenceMutation::Safe(block) => safe = Some(*block), + CanonicalSequenceMutation::Finalized(block) => finalized = Some(*block), + _ => {} + } + } + CanonicalSequenceState::new(history, coverage, safe, finalized) +} + +#[test] +fn provider_neutral_sequence_validator_is_sparse_checkpointable_and_fail_closed() -> Result<()> { + let retained = block(100, B256::repeat_byte(0x64), B256::repeat_byte(0x63)); + let old_tip = block(105, B256::repeat_byte(0x69), B256::repeat_byte(0x68)); + let ancestor = block(103, B256::repeat_byte(0x67), B256::repeat_byte(0x66)); + let new_tip = block(105, B256::repeat_byte(0xf5), B256::repeat_byte(0xf4)); + let state = CanonicalSequenceState::new(vec![retained, old_tip], Some(old_tip), None, None); + let encoded = serde_json::to_vec(&state)?; + let restored: CanonicalSequenceState = serde_json::from_slice(&encoded)?; + assert_eq!( + restored, state, + "callers can checkpoint the validation state" + ); + restored.validate()?; + let mut bounded = restored.clone(); + bounded.retain_recent_history(1); + assert_eq!(bounded.retained_canonical_history(), &[old_tip]); + assert_eq!(bounded.coverage_head(), Some(&old_tip)); + bounded.validate()?; + let invalid_checkpoint = CanonicalSequenceState::new( + vec![ + retained, + BlockRef { + hash: B256::repeat_byte(0xff), + ..retained + }, + ], + Some(retained), + None, + None, + ); + assert!(matches!( + invalid_checkpoint.validate(), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let replacement = block(104, B256::repeat_byte(0xe8), ancestor.hash); + let valid = ReactiveInputBatch::::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip, + }, + ChainControl::CanonicalProgress(replacement), + ]); + let validated = validate_canonical_sequence(&state, &valid)?; + assert_eq!(validated.next_state().coverage_head(), Some(&replacement)); + assert_eq!( + validated.next_state().retained_canonical_history(), + &[retained, ancestor, replacement], + "an unlogged common ancestor is accepted inside sparse retained history" + ); + assert!(matches!( + validated.mutations().first(), + Some(CanonicalSequenceMutation::Rewind { common_ancestor: Some(block), dropped }) + if *block == ancestor && dropped == &[old_tip] + )); + + let outside_horizon = CanonicalSequenceState::new(vec![old_tip], Some(old_tip), None, None); + assert!(matches!( + validate_canonical_sequence(&outside_horizon, &valid), + Err(ReactiveError::InvalidChainControl { .. }) + )); + let diagnostic = validate_canonical_sequence_diagnostic(&outside_horizon, &valid) + .expect_err("durable callers need a stable incomplete-history diagnostic"); + assert!(diagnostic.requires_history()); + assert!(matches!( + diagnostic, + CanonicalSequenceError::IncompleteRollback { + common_ancestor: 103, + oldest_retained: Some(105), + kind: CanonicalRollbackKind::Explicit, + } + )); + + let invalid_snapshot = CanonicalSequenceState::new(vec![old_tip], None, None, None); + let invalid = validate_canonical_sequence_diagnostic(&invalid_snapshot, &valid) + .expect_err("intrinsically malformed state is not history exhaustion"); + assert!(!invalid.requires_history()); + assert!(matches!(invalid, CanonicalSequenceError::Invalid(_))); + + let conflicting = ReactiveInputBatch::::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::CanonicalProgress(replacement), + ChainControl::Safe(BlockRef { + hash: B256::repeat_byte(0xff), + ..replacement + }), + ]); + assert!(matches!( + validate_canonical_sequence(validated.next_state(), &conflicting), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let overlap = ReactiveInputBatch::::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::CanonicalProgress(ancestor), + ChainControl::Barrier { + id: b"overlap-cutover".to_vec(), + block: Some(replacement), + }, + ]); + let normalized = normalize_and_validate_canonical_sequence(validated.next_state(), &overlap)?; + assert_eq!( + normalized.normalized_chain_controls(), + &[ChainControl::Barrier { + id: b"overlap-cutover".to_vec(), + block: None, + }] + ); + Ok(()) +} + +#[test] +fn sequence_validator_emits_replayable_rewinds_for_implicit_replacements() -> Result<()> { + let parent = block(10, B256::repeat_byte(0x10), B256::repeat_byte(0x09)); + let old_11 = block(11, B256::repeat_byte(0x11), parent.hash); + let old_12 = block(12, B256::repeat_byte(0x12), old_11.hash); + let replacement_11 = block(11, B256::repeat_byte(0xa1), parent.hash); + let same_height_12 = block(12, B256::repeat_byte(0xa2), old_11.hash); + + for (initial, replacement, expected_anchor, expected_dropped) in [ + ( + CanonicalSequenceState::new(vec![parent, old_11, old_12], Some(old_12), None, None), + same_height_12, + old_11, + vec![old_12], + ), + ( + CanonicalSequenceState::new(vec![parent, old_11, old_12], Some(old_12), None, None), + replacement_11, + parent, + vec![old_11, old_12], + ), + ] { + let batch = ReactiveInputBatch::new(vec![sequence_log_record(replacement, 0, false)]); + let validation = validate_canonical_sequence(&initial, &batch)?; + assert_eq!( + validation.mutations(), + &[ + CanonicalSequenceMutation::Rewind { + common_ancestor: Some(expected_anchor), + dropped: expected_dropped, + }, + CanonicalSequenceMutation::Canonical(replacement), + ] + ); + assert_eq!( + replay_sequence_mutations(&initial, validation.mutations()), + *validation.next_state(), + "the public mutations reproduce the validated next state" + ); + } + + let finalized_parent = block(20, B256::repeat_byte(0x20), B256::repeat_byte(0x19)); + let old_child = block(21, B256::repeat_byte(0x21), finalized_parent.hash); + let replacement_child = block(21, B256::repeat_byte(0xb1), finalized_parent.hash); + let finalized_state = CanonicalSequenceState::new( + vec![old_child], + Some(old_child), + None, + Some(finalized_parent), + ); + let validation = validate_canonical_sequence( + &finalized_state, + &ReactiveInputBatch::new(vec![sequence_log_record(replacement_child, 1, false)]), + )?; + assert_eq!( + validation.mutations(), + &[ + CanonicalSequenceMutation::Rewind { + common_ancestor: Some(finalized_parent), + dropped: vec![old_child], + }, + CanonicalSequenceMutation::Canonical(replacement_child), + ] + ); + assert_eq!( + replay_sequence_mutations(&finalized_state, validation.mutations()), + *validation.next_state() + ); + Ok(()) +} + +#[test] +fn implicit_replacement_diagnostics_separate_invalid_input_from_missing_history() { + let parent = block(10, B256::repeat_byte(0x10), B256::repeat_byte(0x09)); + let old_tip = block(11, B256::repeat_byte(0x11), parent.hash); + let parentless_replacement = BlockRef { + number: old_tip.number, + hash: B256::repeat_byte(0xa1), + parent_hash: None, + timestamp: old_tip.timestamp, + }; + let sparse = CanonicalSequenceState::new(vec![old_tip], Some(old_tip), None, None); + let parentless = validate_canonical_sequence_diagnostic( + &sparse, + &ReactiveInputBatch::new(vec![sequence_log_record(parentless_replacement, 0, false)]), + ) + .expect_err("an implicit replacement must identify its parent"); + assert!(!parentless.requires_history()); + assert!(matches!( + parentless, + CanonicalSequenceError::Invalid(ReactiveError::InvalidChainControl { .. }) + )); + + let conflicting_replacement = block( + old_tip.number, + B256::repeat_byte(0xa2), + B256::repeat_byte(0xfe), + ); + let retained = CanonicalSequenceState::new(vec![parent, old_tip], Some(old_tip), None, None); + let conflicting = validate_canonical_sequence_diagnostic( + &retained, + &ReactiveInputBatch::new(vec![sequence_log_record(conflicting_replacement, 1, false)]), + ) + .expect_err("a supplied parent cannot contradict the exact retained predecessor"); + assert!(!conflicting.requires_history()); + assert!(matches!( + conflicting, + CanonicalSequenceError::Invalid(ReactiveError::InvalidChainControl { .. }) + )); + + let missing_history_replacement = block(old_tip.number, B256::repeat_byte(0xa3), parent.hash); + let missing_history = validate_canonical_sequence_diagnostic( + &sparse, + &ReactiveInputBatch::new(vec![sequence_log_record( + missing_history_replacement, + 2, + false, + )]), + ) + .expect_err("a supplied but unretained parent may require deeper history"); + assert!(missing_history.requires_history()); + assert!(matches!( + missing_history, + CanonicalSequenceError::IncompleteRollback { + common_ancestor: 10, + oldest_retained: Some(11), + kind: CanonicalRollbackKind::ImplicitParent, + } + )); +} + +#[test] +fn sequence_validator_requires_the_parent_at_the_exact_adjacent_height() { + let reused_hash = B256::repeat_byte(0x42); + let non_parent = block(2, reused_hash, B256::repeat_byte(0x01)); + let old_tip = block(4, B256::repeat_byte(0x44), B256::repeat_byte(0x43)); + let replacement = block(4, B256::repeat_byte(0xf4), reused_hash); + let state = CanonicalSequenceState::new(vec![non_parent, old_tip], Some(old_tip), None, None); + let batch = ReactiveInputBatch::new(vec![sequence_log_record(replacement, 0, false)]); + assert!(matches!( + validate_canonical_sequence(&state, &batch), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let sparse_state = CanonicalSequenceState::new(vec![non_parent], Some(non_parent), None, None); + let impossible_gap_child = block(4, B256::repeat_byte(0xf4), reused_hash); + assert!(matches!( + validate_canonical_sequence( + &sparse_state, + &ReactiveInputBatch::new(vec![sequence_log_record(impossible_gap_child, 0, false,)]), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); + assert!(matches!( + validate_canonical_sequence( + &sparse_state, + &ReactiveInputBatch::::new(Vec::new()) + .with_chain_controls([ChainControl::CanonicalProgress(impossible_gap_child),]), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); + assert!(matches!( + CanonicalSequenceState::new( + vec![non_parent, impossible_gap_child], + Some(impossible_gap_child), + None, + None, + ) + .validate(), + Err(ReactiveError::InvalidChainControl { .. }) + )); +} + +#[test] +fn parentless_removed_tip_can_use_an_exact_retained_predecessor() -> Result<()> { + let parent = block(30, B256::repeat_byte(0x30), B256::repeat_byte(0x29)); + let old_tip = BlockRef { + number: 31, + hash: B256::repeat_byte(0x31), + parent_hash: None, + timestamp: Some(1_700_000_031), + }; + let replacement = BlockRef { + hash: B256::repeat_byte(0xb1), + ..old_tip + }; + let resolved_replacement = BlockRef { + parent_hash: Some(parent.hash), + ..replacement + }; + let state = + CanonicalSequenceState::new(vec![parent, old_tip], Some(old_tip), None, Some(parent)); + let batch = ReactiveInputBatch::new(vec![ + sequence_log_record(replacement, 1, false), + sequence_log_record(old_tip, 0, true), + ]); + let validation = validate_canonical_sequence(&state, &batch)?; + assert_eq!( + validation.mutations(), + &[ + CanonicalSequenceMutation::Rewind { + common_ancestor: Some(parent), + dropped: vec![old_tip], + }, + CanonicalSequenceMutation::Canonical(resolved_replacement), + ] + ); + assert_eq!( + replay_sequence_mutations(&state, validation.mutations()), + *validation.next_state() + ); + Ok(()) +} + +#[tokio::test] +async fn runtime_parentless_replacement_uses_the_same_batch_removed_proof() -> Result<()> { + let parent = block(30, B256::repeat_byte(0x30), B256::repeat_byte(0x29)); + let old_tip = BlockRef { + number: 31, + hash: B256::repeat_byte(0x31), + parent_hash: None, + timestamp: Some(1_700_000_031), + }; + let replacement = BlockRef { + hash: B256::repeat_byte(0xb1), + ..old_tip + }; + let resolved_replacement = BlockRef { + parent_hash: Some(parent.hash), + ..replacement + }; + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record(parent, 0, false)]), + )?; + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::CanonicalProgress(old_tip)]), + )?; + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ + sequence_log_record(replacement, 1, false), + sequence_log_record(old_tip, 0, true), + ]), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(resolved_replacement)); + assert_eq!(runtime.metrics().deep_reorgs, 0); + assert_eq!( + runtime.health(), + evm_fork_cache::reactive::CacheHealth::Healthy + ); + Ok(()) +} + +#[test] +fn normalized_equal_coverage_retains_metadata_enrichment_but_older_enrichment_is_non_forwarding() +-> Result<()> { + let parent = B256::repeat_byte(0x40); + let sparse = BlockRef { + number: 41, + hash: B256::repeat_byte(0x41), + parent_hash: None, + timestamp: None, + }; + let enriched = block(41, sparse.hash, parent); + let sparse_state = CanonicalSequenceState::new(vec![sparse], Some(sparse), None, None); + + for control in [ + ChainControl::CanonicalProgress(enriched), + ChainControl::Barrier { + id: b"equal-enrichment".to_vec(), + block: Some(enriched), + }, + ] { + let validation = normalize_and_validate_canonical_sequence( + &sparse_state, + &ReactiveInputBatch::::new(Vec::new()).with_chain_controls([control.clone()]), + )?; + assert_eq!(validation.normalized_chain_controls(), &[control]); + assert_eq!(validation.next_state().coverage_head(), Some(&enriched)); + assert_eq!( + validation.next_state().retained_canonical_history(), + &[enriched] + ); + } + + let head = block(42, B256::repeat_byte(0x42), sparse.hash); + let advanced = CanonicalSequenceState::new(vec![sparse, head], Some(head), None, None); + let older = ReactiveInputBatch::::new(Vec::new()).with_chain_controls([ + ChainControl::CanonicalProgress(enriched), + ChainControl::Barrier { + id: b"older-enrichment".to_vec(), + block: Some(enriched), + }, + ]); + let normalized = normalize_and_validate_canonical_sequence(&advanced, &older)?; + assert_eq!(normalized.next_state(), &advanced); + assert_eq!( + normalized.normalized_chain_controls(), + &[ChainControl::Barrier { + id: b"older-enrichment".to_vec(), + block: None, + }] + ); + Ok(()) +} + +#[test] +fn sequence_finality_never_advances_beyond_coverage() -> Result<()> { + let covered = block(50, B256::repeat_byte(0x50), B256::repeat_byte(0x49)); + let next = block(51, B256::repeat_byte(0x51), covered.hash); + assert!(matches!( + CanonicalSequenceState::new(vec![covered], Some(covered), Some(next), None).validate(), + Err(ReactiveError::InvalidChainControl { .. }) + )); + let state = CanonicalSequenceState::new(vec![covered], Some(covered), None, None); + let ahead = ReactiveInputBatch::::new(Vec::new()) + .with_chain_controls([ChainControl::Safe(next)]); + assert!(matches!( + validate_canonical_sequence(&state, &ahead), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let certified = ReactiveInputBatch::::new(Vec::new()).with_chain_controls([ + ChainControl::CanonicalProgress(next), + ChainControl::Safe(next), + ChainControl::Finalized(next), + ]); + let validation = validate_canonical_sequence(&state, &certified)?; + assert_eq!(validation.next_state().coverage_head(), Some(&next)); + assert_eq!(validation.next_state().safe_head(), Some(&next)); + assert_eq!(validation.next_state().finalized_head(), Some(&next)); + Ok(()) +} + +#[test] +fn sequence_snapshots_reject_broken_adjacent_links() { + let parent = block(60, B256::repeat_byte(0x60), B256::repeat_byte(0x59)); + let wrong_child = block(61, B256::repeat_byte(0x61), B256::repeat_byte(0xff)); + assert!(matches!( + CanonicalSequenceState::new(vec![parent], None, None, None).validate(), + Err(ReactiveError::InvalidChainControl { .. }) + )); + assert!(matches!( + CanonicalSequenceState::new(vec![parent, wrong_child], Some(wrong_child), None, None,) + .validate(), + Err(ReactiveError::InvalidChainControl { .. }) + )); + assert!(matches!( + CanonicalSequenceState::new(vec![parent], Some(wrong_child), None, None).validate(), + Err(ReactiveError::InvalidChainControl { .. }) + )); + let safe_with_wrong_finalized_parent = BlockRef { + number: 61, + hash: B256::repeat_byte(0x61), + parent_hash: Some(B256::repeat_byte(0xfe)), + timestamp: Some(1_700_000_061), + }; + assert!(matches!( + CanonicalSequenceState::new( + vec![safe_with_wrong_finalized_parent], + Some(safe_with_wrong_finalized_parent), + Some(safe_with_wrong_finalized_parent), + Some(parent), + ) + .validate(), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let finality = block(70, B256::repeat_byte(0x70), B256::repeat_byte(0x69)); + let wrong_coverage = block(71, B256::repeat_byte(0x71), B256::repeat_byte(0xff)); + for (safe, finalized) in [ + (Some(finality), None), + (None, Some(finality)), + (Some(finality), Some(finality)), + ] { + assert!(matches!( + CanonicalSequenceState::new(Vec::new(), Some(wrong_coverage), safe, finalized,) + .validate(), + Err(ReactiveError::InvalidChainControl { .. }) + )); + } + let linked_coverage = BlockRef { + parent_hash: Some(finality.hash), + ..wrong_coverage + }; + CanonicalSequenceState::new( + Vec::new(), + Some(linked_coverage), + Some(finality), + Some(finality), + ) + .validate() + .expect("adjacent coverage may descend from the exact safe/finalized head"); + + let retained = block(80, B256::repeat_byte(0x80), B256::repeat_byte(0x79)); + let sparse_coverage = BlockRef { + number: 81, + hash: B256::repeat_byte(0x81), + parent_hash: None, + timestamp: None, + }; + let conflicting_alias = BlockRef { + parent_hash: Some(B256::repeat_byte(0xfe)), + ..sparse_coverage + }; + assert!(matches!( + CanonicalSequenceState::new( + vec![retained], + Some(sparse_coverage), + Some(conflicting_alias), + None, + ) + .validate(), + Err(ReactiveError::InvalidChainControl { .. }) + )); + CanonicalSequenceState::new( + vec![retained], + Some(sparse_coverage), + Some(BlockRef { + parent_hash: Some(retained.hash), + ..sparse_coverage + }), + None, + ) + .validate() + .expect("same-height finality metadata may enrich sparse coverage compatibly"); + + let reused = B256::repeat_byte(0xaa); + let first_use = block(90, reused, B256::repeat_byte(0x89)); + let second_use = BlockRef { + number: 92, + hash: reused, + parent_hash: None, + timestamp: Some(1_700_000_092), + }; + assert!(matches!( + CanonicalSequenceState::new(vec![first_use, second_use], Some(second_use), None, None,) + .validate(), + Err(ReactiveError::InvalidChainControl { .. }) + )); + let first_use_state = CanonicalSequenceState::new(vec![first_use], Some(first_use), None, None); + assert!(matches!( + validate_canonical_sequence( + &first_use_state, + &ReactiveInputBatch::::new(Vec::new()) + .with_chain_controls([ChainControl::CanonicalProgress(second_use),]), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); +} + +#[test] +fn explicit_reorg_span_cannot_suppress_removal_of_its_new_tip() { + let ancestor = block(70, B256::repeat_byte(0x70), B256::repeat_byte(0x69)); + let old_tip = block(71, B256::repeat_byte(0x71), ancestor.hash); + let new_tip = block(71, B256::repeat_byte(0xf1), ancestor.hash); + let state = CanonicalSequenceState::new(vec![ancestor, old_tip], Some(old_tip), None, None); + let batch = ReactiveInputBatch::new(vec![sequence_log_record(new_tip, 0, true)]) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip, + }]); + assert!(matches!( + validate_canonical_sequence(&state, &batch), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let removed_and_canonical = ReactiveInputBatch::new(vec![ + sequence_log_record(old_tip, 0, true), + sequence_log_record(old_tip, 0, false), + ]); + assert!(matches!( + validate_canonical_sequence(&state, &removed_and_canonical), + Err(ReactiveError::InvalidChainControl { .. }) + )); +} + +#[test] +fn removed_identity_cannot_be_reasserted_by_any_post_record_control() { + let parent = block(72, B256::repeat_byte(0x72), B256::repeat_byte(0x71)); + let removed = block(73, B256::repeat_byte(0x73), parent.hash); + let state = CanonicalSequenceState::new(vec![parent, removed], Some(removed), None, None); + + for control in [ + ChainControl::CanonicalProgress(removed), + ChainControl::Barrier { + id: b"removed-tip".to_vec(), + block: Some(removed), + }, + ChainControl::Safe(removed), + ChainControl::Finalized(removed), + ] { + let error = validate_canonical_sequence( + &state, + &ReactiveInputBatch::new(vec![sequence_log_record(removed, 0, true)]) + .with_chain_controls([control.clone()]), + ) + .expect_err("a post-record control cannot reassert a removed identity"); + assert!( + matches!(error, ReactiveError::InvalidChainControl { ref message } + if message.contains("also removed")), + "{control:?} returned the wrong contradiction: {error}" + ); + } + + let conflicting_removed = BlockRef { + timestamp: removed.timestamp.map(|timestamp| timestamp + 1), + ..removed + }; + assert!(matches!( + validate_canonical_sequence( + &state, + &ReactiveInputBatch::new(vec![ + sequence_log_record(removed, 0, true), + sequence_log_record(conflicting_removed, 1, true), + ]), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let reused_removed_hash = BlockRef { + number: removed.number + 1, + hash: removed.hash, + parent_hash: Some(B256::repeat_byte(0xfe)), + timestamp: removed.timestamp.map(|timestamp| timestamp + 1), + }; + let duplicate_height_error = validate_canonical_sequence_diagnostic( + &state, + &ReactiveInputBatch::new(vec![ + sequence_log_record(removed, 2, true), + sequence_log_record(reused_removed_hash, 3, true), + ]), + ) + .expect_err("one removed hash cannot identify two heights"); + assert!(matches!( + duplicate_height_error, + CanonicalSequenceError::Invalid(_) + )); + let removed_then_reused_control = validate_canonical_sequence_diagnostic( + &state, + &ReactiveInputBatch::new(vec![sequence_log_record(removed, 4, true)]) + .with_chain_controls([ChainControl::CanonicalProgress(reused_removed_hash)]), + ) + .expect_err("a removed hash cannot be canonically asserted at another height"); + assert!(matches!( + removed_then_reused_control, + CanonicalSequenceError::Invalid(_) + )); +} + +#[test] +fn explicit_reorg_only_suppresses_exact_old_branch_removals() { + let ancestor = block(10, B256::repeat_byte(0x10), B256::repeat_byte(0x09)); + let old_11 = block(11, B256::repeat_byte(0x11), ancestor.hash); + let old_tip = block(12, B256::repeat_byte(0x12), old_11.hash); + let new_tip = block(12, B256::repeat_byte(0xb2), B256::repeat_byte(0xb1)); + let foreign_11 = block(11, B256::repeat_byte(0xf1), ancestor.hash); + let state = + CanonicalSequenceState::new(vec![ancestor, old_11, old_tip], Some(old_tip), None, None); + let batch = ReactiveInputBatch::new(vec![sequence_log_record(foreign_11, 0, true)]) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip, + }]); + + assert!(matches!( + validate_canonical_sequence(&state, &batch), + Err(ReactiveError::InvalidChainControl { .. }) + )); +} + +#[tokio::test] +async fn removed_genesis_is_rejected_without_mutating_runtime_state() -> Result<()> { + let address = Address::repeat_byte(0xb0); + let genesis = block(0, B256::repeat_byte(0x01), B256::ZERO); + let state = CanonicalSequenceState::new(vec![genesis], Some(genesis), None, None); + let removed = ReactiveInputBatch::new(vec![sequence_log_record(genesis, 0, true)]); + assert!(matches!( + validate_canonical_sequence(&state, &removed), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Genesis()")], + &genesis, + 0, + 0, + false, + )), + included_context(genesis, 0), + ), + )?; + let error = runtime + .ingest_batch(&mut cache, removed) + .expect_err("genesis removal must fail atomically"); + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), Some(genesis)); + Ok(()) +} + +#[test] +fn removed_tip_uses_its_authenticated_parent_as_the_rewind_anchor() -> Result<()> { + let parent = block(20, B256::repeat_byte(0x20), B256::repeat_byte(0x19)); + let old_tip = block(21, B256::repeat_byte(0x21), parent.hash); + let replacement = block(21, B256::repeat_byte(0xb1), parent.hash); + let batch = ReactiveInputBatch::new(vec![ + sequence_log_record(old_tip, 0, true), + sequence_log_record(replacement, 1, false), + ]); + + for (safe, finalized) in [ + (Some(parent), None), + (None, Some(parent)), + (Some(parent), Some(parent)), + ] { + let initial = CanonicalSequenceState::new(vec![old_tip], Some(old_tip), safe, finalized); + let validation = validate_canonical_sequence(&initial, &batch)?; + assert_eq!( + validation.mutations(), + &[ + CanonicalSequenceMutation::Rewind { + common_ancestor: Some(parent), + dropped: vec![old_tip], + }, + CanonicalSequenceMutation::Canonical(replacement), + ] + ); + assert_eq!(validation.next_state().coverage_head(), Some(&replacement)); + assert_eq!(validation.next_state().safe_head(), safe.as_ref()); + assert_eq!(validation.next_state().finalized_head(), finalized.as_ref()); + assert_eq!( + replay_sequence_mutations(&initial, validation.mutations()), + *validation.next_state() + ); + } + + let partial_removed = BlockRef { + parent_hash: None, + timestamp: None, + ..old_tip + }; + let partial = validate_canonical_sequence( + &CanonicalSequenceState::new(vec![old_tip], Some(old_tip), None, Some(parent)), + &ReactiveInputBatch::new(vec![ + sequence_log_record(partial_removed, 2, true), + sequence_log_record(replacement, 3, false), + ]), + )?; + assert!(matches!( + partial.mutations().first(), + Some(CanonicalSequenceMutation::Rewind { + common_ancestor: Some(anchor), + dropped, + }) if *anchor == parent && dropped == &[old_tip] + )); + + let conflicting_removed = BlockRef { + timestamp: old_tip.timestamp.map(|timestamp| timestamp + 1), + ..old_tip + }; + assert!(matches!( + validate_canonical_sequence( + &CanonicalSequenceState::new(vec![old_tip], Some(old_tip), None, None), + &ReactiveInputBatch::new(vec![sequence_log_record(conflicting_removed, 4, true,)]), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); + Ok(()) +} + +#[test] +fn known_removed_anchor_mismatch_is_invalid_without_more_history() { + let parent = block(20, B256::repeat_byte(0x20), B256::repeat_byte(0x19)); + let old_tip = block(21, B256::repeat_byte(0x21), parent.hash); + let replacement = block(21, B256::repeat_byte(0xb1), B256::repeat_byte(0xfe)); + let initial = + CanonicalSequenceState::new(vec![old_tip], Some(old_tip), Some(parent), Some(parent)); + let batches = [ + ReactiveInputBatch::new(vec![ + sequence_log_record(old_tip, 0, true), + sequence_log_record(replacement, 1, false), + ]), + ReactiveInputBatch::new(vec![sequence_log_record(old_tip, 0, true)]) + .with_chain_controls([ChainControl::CanonicalProgress(replacement)]), + ]; + + for batch in batches { + let error = validate_canonical_sequence_diagnostic(&initial, &batch) + .expect_err("a replacement that contradicts a known parent must fail"); + assert!( + !error.requires_history(), + "older history cannot repair a known parent contradiction: {error:?}" + ); + assert!(matches!( + error, + CanonicalSequenceError::Invalid(ReactiveError::InvalidChainControl { .. }) + )); + } +} + +#[test] +fn removed_metadata_cannot_contradict_the_exact_retained_predecessor() { + let parent = block(20, B256::repeat_byte(0x20), B256::repeat_byte(0x19)); + let partial_old_tip = BlockRef { + number: 21, + hash: B256::repeat_byte(0x21), + parent_hash: None, + timestamp: Some(1_700_000_021), + }; + let conflicting_removed = BlockRef { + parent_hash: Some(B256::repeat_byte(0xfe)), + ..partial_old_tip + }; + let initial = CanonicalSequenceState::new( + vec![parent, partial_old_tip], + Some(partial_old_tip), + None, + None, + ); + + let error = validate_canonical_sequence_diagnostic( + &initial, + &ReactiveInputBatch::new(vec![sequence_log_record(conflicting_removed, 0, true)]), + ) + .expect_err("removed metadata cannot contradict an exact retained parent"); + assert!(!error.requires_history()); + assert!(matches!( + error, + CanonicalSequenceError::Invalid(ReactiveError::InvalidChainControl { .. }) + )); +} + +#[test] +fn removed_identity_cannot_reuse_a_hash_from_another_retained_height() { + let parent = block(5, B256::repeat_byte(0x55), B256::repeat_byte(0x44)); + let current = block(6, B256::repeat_byte(0x66), parent.hash); + let impossible_removed = BlockRef { + number: current.number, + hash: parent.hash, + parent_hash: None, + timestamp: current.timestamp, + }; + let initial = CanonicalSequenceState::new(vec![parent, current], Some(current), None, None); + + let error = validate_canonical_sequence_diagnostic( + &initial, + &ReactiveInputBatch::new(vec![sequence_log_record(impossible_removed, 0, true)]), + ) + .expect_err("one block hash cannot identify two canonical heights"); + assert!(!error.requires_history()); + assert!(matches!( + error, + CanonicalSequenceError::Invalid(ReactiveError::InvalidChainControl { .. }) + )); +} + +#[test] +fn explicit_reorg_assertion_authenticates_a_partial_new_tip_record() -> Result<()> { + let ancestor = block(40, B256::repeat_byte(0x40), B256::repeat_byte(0x39)); + let old_tip = block(41, B256::repeat_byte(0x41), ancestor.hash); + let new_tip = block(41, B256::repeat_byte(0xf1), ancestor.hash); + let partial_new_tip = BlockRef { + parent_hash: None, + timestamp: None, + ..new_tip + }; + let initial = CanonicalSequenceState::new( + vec![ancestor, old_tip], + Some(old_tip), + Some(ancestor), + Some(ancestor), + ); + let envelope = ReactiveInputBatch::new(vec![sequence_log_record(partial_new_tip, 0, false)]) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip, + }]); + + let validation = validate_canonical_sequence_diagnostic(&initial, &envelope)?; + assert_eq!(validation.next_state().coverage_head(), Some(&new_tip)); + assert_eq!(validation.next_state().safe_head(), Some(&ancestor)); + assert_eq!(validation.next_state().finalized_head(), Some(&ancestor)); + assert!(matches!( + validation.mutations().last(), + Some(CanonicalSequenceMutation::Canonical(block)) if *block == new_tip + )); + Ok(()) +} + +#[tokio::test] +async fn runtime_retains_metadata_resolved_for_a_partial_explicit_new_tip() -> Result<()> { + let ancestor = block(50, B256::repeat_byte(0x50), B256::repeat_byte(0x49)); + let old_tip = block(51, B256::repeat_byte(0x51), ancestor.hash); + let new_tip = block(51, B256::repeat_byte(0xf1), ancestor.hash); + let partial_new_tip = BlockRef { + parent_hash: None, + timestamp: None, + ..new_tip + }; + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + for (block, log_index) in [(ancestor, 0), (old_tip, 1)] { + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record(block, log_index, false)]), + )?; + } + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record(partial_new_tip, 2, false)]) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip, + }]), + )?; + assert_eq!(runtime.last_canonical_block(), Some(new_tip)); + + let conflicting = BlockRef { + timestamp: new_tip.timestamp.map(|timestamp| timestamp + 1), + ..new_tip + }; + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::::new(Vec::new()) + .with_chain_controls([ChainControl::CanonicalProgress(conflicting)]), + ) + .expect_err("resolved metadata must remain authoritative across batches"); + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), Some(new_tip)); + Ok(()) +} + +#[tokio::test] +async fn exact_post_controls_authenticate_compact_records_and_survive_normalization() -> Result<()> +{ + let parent = block(60, B256::repeat_byte(0x60), B256::repeat_byte(0x59)); + let child = block(61, B256::repeat_byte(0x61), parent.hash); + let partial_child = BlockRef { + parent_hash: None, + timestamp: None, + ..child + }; + let initial = CanonicalSequenceState::new(vec![parent], Some(parent), None, None); + let controls = [ + ChainControl::CanonicalProgress(child), + ChainControl::Barrier { + id: b"compact-proof".to_vec(), + block: Some(child), + }, + ChainControl::Safe(child), + ChainControl::Finalized(child), + ]; + + for control in controls { + let envelope = ReactiveInputBatch::new(vec![sequence_log_record(partial_child, 0, false)]) + .with_chain_controls([control.clone()]); + let validation = normalize_and_validate_canonical_sequence(&initial, &envelope)?; + assert_eq!(validation.next_state().coverage_head(), Some(&child)); + assert_eq!( + validation.normalized_chain_controls(), + std::slice::from_ref(&control), + "proof-bearing controls must survive normalization" + ); + + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record(parent, 0, false)]), + )?; + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record(partial_child, 1, false)]) + .with_chain_controls(validation.normalized_chain_controls().iter().cloned()), + )?; + assert_eq!(runtime.last_canonical_block(), Some(child)); + } + + let conflicting_child = BlockRef { + timestamp: child.timestamp.map(|timestamp| timestamp + 1), + ..child + }; + let timestamped_partial = BlockRef { + parent_hash: None, + ..child + }; + let conflict = validate_canonical_sequence_diagnostic( + &initial, + &ReactiveInputBatch::new(vec![sequence_log_record(timestamped_partial, 2, false)]) + .with_chain_controls([ChainControl::Safe(conflicting_child)]), + ) + .expect_err("a post-control proof cannot contradict record metadata"); + assert!(!conflict.requires_history()); + assert!(matches!( + conflict, + CanonicalSequenceError::Invalid(ReactiveError::InvalidChainControl { .. }) + )); + Ok(()) +} + +#[tokio::test] +async fn log_payload_timestamp_participates_in_canonical_metadata_resolution() -> Result<()> { + let parent = block(70, B256::repeat_byte(0x70), B256::repeat_byte(0x69)); + let child = block(71, B256::repeat_byte(0x71), parent.hash); + let partial_context = BlockRef { + timestamp: None, + ..child + }; + let initial = CanonicalSequenceState::new(vec![parent], Some(parent), None, None); + + let compatible = validate_canonical_sequence_diagnostic( + &initial, + &ReactiveInputBatch::new(vec![sequence_log_record_with_context_block( + child, + partial_context, + 0, + false, + )]), + )?; + assert_eq!(compatible.next_state().coverage_head(), Some(&child)); + + let conflicting = BlockRef { + timestamp: child.timestamp.map(|timestamp| timestamp + 1), + ..child + }; + for (state, controls) in [ + ( + initial.clone(), + vec![ChainControl::CanonicalProgress(conflicting)], + ), + ( + CanonicalSequenceState::new(vec![conflicting], Some(conflicting), None, None), + Vec::new(), + ), + ] { + let error = validate_canonical_sequence_diagnostic( + &state, + &ReactiveInputBatch::new(vec![sequence_log_record_with_context_block( + child, + partial_context, + 1, + false, + )]) + .with_chain_controls(controls), + ) + .expect_err("payload timestamps cannot conflict with canonical state or controls"); + assert!(!error.requires_history()); + assert!(matches!( + error, + CanonicalSequenceError::Invalid(ReactiveError::InvalidChainControl { .. }) + )); + } + + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record(parent, 0, false)]), + )?; + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record_with_context_block( + child, + partial_context, + 1, + false, + )]) + .with_chain_controls([ChainControl::CanonicalProgress(conflicting)]), + ) + .expect_err("payload/control timestamp conflict must fail atomically"); + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), Some(parent)); + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record_with_context_block( + child, + partial_context, + 2, + false, + )]), + )?; + assert_eq!(runtime.last_canonical_block(), Some(child)); + Ok(()) +} + +#[test] +fn sparse_removed_tip_advances_to_its_authenticated_parent_and_can_continue() -> Result<()> { + let retained = block(90, B256::repeat_byte(0x90), B256::repeat_byte(0x89)); + let finalized = block(100, B256::repeat_byte(0x64), B256::repeat_byte(0x63)); + let parent = BlockRef { + number: 109, + hash: B256::repeat_byte(0x6d), + parent_hash: None, + timestamp: None, + }; + let removed = block(110, B256::repeat_byte(0x6e), parent.hash); + let replacement = block(110, B256::repeat_byte(0xee), parent.hash); + let initial = CanonicalSequenceState::new( + vec![retained, removed], + Some(removed), + Some(finalized), + Some(finalized), + ); + + let removed_only = validate_canonical_sequence( + &initial, + &ReactiveInputBatch::new(vec![sequence_log_record(removed, 0, true)]), + )?; + assert_eq!( + removed_only.mutations(), + &[CanonicalSequenceMutation::Rewind { + common_ancestor: Some(parent), + dropped: vec![removed], + }] + ); + assert_eq!( + removed_only.next_state(), + &CanonicalSequenceState::new( + vec![retained], + Some(parent), + Some(finalized), + Some(finalized), + ) + ); + assert_eq!( + replay_sequence_mutations(&initial, removed_only.mutations()), + *removed_only.next_state() + ); + + let continued = validate_canonical_sequence( + removed_only.next_state(), + &ReactiveInputBatch::new(vec![sequence_log_record(replacement, 1, false)]), + )?; + assert_eq!(continued.next_state().coverage_head(), Some(&replacement)); + assert_eq!(continued.next_state().safe_head(), Some(&finalized)); + assert_eq!(continued.next_state().finalized_head(), Some(&finalized)); + Ok(()) +} + +#[tokio::test] +async fn runtime_removed_sole_tip_preserves_authenticated_safe_and_finalized_parent() -> Result<()> +{ + let address = Address::repeat_byte(0xb2); + let parent = block(20, B256::repeat_byte(0x20), B256::repeat_byte(0x19)); + let old_tip = block(21, B256::repeat_byte(0x21), parent.hash); + let replacement = block(21, B256::repeat_byte(0xb1), parent.hash); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig { + journal_depth: 1, + ..ReactiveConfig::default() + }); + for (canonical, log_index) in [(parent, 0), (old_tip, 1)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"RemovedFinalityParity()")], + &canonical, + 0, + log_index, + false, + )), + included_context(canonical, log_index), + ), + )?; + } + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Safe(parent), ChainControl::Finalized(parent)]), + )?; + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ + sequence_log_record( + BlockRef { + parent_hash: None, + timestamp: None, + ..old_tip + }, + 0, + true, + ), + sequence_log_record(replacement, 1, false), + ]), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(replacement)); + assert_eq!(runtime.safe_head(), Some(&parent)); + assert_eq!(runtime.finalized_head(), Some(&parent)); + Ok(()) +} + +#[tokio::test] +async fn runtime_sparse_removal_installs_parent_coverage_before_continuation() -> Result<()> { + let address = Address::repeat_byte(0xb3); + let retained = block(90, B256::repeat_byte(0x90), B256::repeat_byte(0x89)); + let finalized = block(100, B256::repeat_byte(0x64), B256::repeat_byte(0x63)); + let parent = BlockRef { + number: 109, + hash: B256::repeat_byte(0x6d), + parent_hash: None, + timestamp: None, + }; + let removed = block(110, B256::repeat_byte(0x6e), parent.hash); + let replacement = block(110, B256::repeat_byte(0xee), parent.hash); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + for (canonical, log_index) in [(retained, 0), (removed, 1)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"SparseRemovedContinuation()")], + &canonical, + 0, + log_index, + false, + )), + included_context(canonical, log_index), + ), + )?; + } + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::Safe(finalized), + ChainControl::Finalized(finalized), + ]), + )?; + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record(removed, 0, true)]), + )?; + assert_eq!(runtime.last_canonical_block(), Some(parent)); + assert_eq!(runtime.safe_head(), Some(&finalized)); + assert_eq!(runtime.finalized_head(), Some(&finalized)); + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record(replacement, 1, false)]), + )?; + assert_eq!(runtime.last_canonical_block(), Some(replacement)); + assert_eq!(runtime.safe_head(), Some(&finalized)); + assert_eq!(runtime.finalized_head(), Some(&finalized)); + Ok(()) +} + +#[test] +fn removed_anchor_survives_older_and_ancestor_records_until_replacement() -> Result<()> { + let older = block(90, B256::repeat_byte(0x90), B256::repeat_byte(0x89)); + let parent = block(109, B256::repeat_byte(0x6d), B256::repeat_byte(0x6c)); + let removed = BlockRef { + number: 110, + hash: B256::repeat_byte(0x6e), + parent_hash: None, + timestamp: Some(1_700_000_110), + }; + let replacement = BlockRef { + hash: B256::repeat_byte(0xee), + ..removed + }; + let resolved_replacement = BlockRef { + parent_hash: Some(parent.hash), + ..replacement + }; + let initial = + CanonicalSequenceState::new(vec![older, parent, removed], Some(removed), None, None); + let batch = ReactiveInputBatch::new(vec![ + sequence_log_record(removed, 0, true), + sequence_log_record(older, 1, false), + sequence_log_record(parent, 2, false), + sequence_log_record(replacement, 3, false), + ]); + + let validation = validate_canonical_sequence(&initial, &batch)?; + assert_eq!( + validation.next_state().coverage_head(), + Some(&resolved_replacement) + ); + assert_eq!( + validation.next_state().retained_canonical_history(), + &[older, parent, resolved_replacement] + ); + assert!(matches!( + validation.mutations().first(), + Some(CanonicalSequenceMutation::Rewind { + common_ancestor: Some(anchor), + dropped, + }) if *anchor == parent && dropped == &[removed] + )); + assert_eq!( + replay_sequence_mutations(&initial, validation.mutations()), + *validation.next_state() + ); + Ok(()) +} + +#[test] +fn explicit_rewind_and_finality_mutations_carry_resolved_metadata() -> Result<()> { + let ancestor = block(30, B256::repeat_byte(0x30), B256::repeat_byte(0x29)); + let old_tip = block(31, B256::repeat_byte(0x31), ancestor.hash); + let new_tip = block(31, B256::repeat_byte(0xb1), ancestor.hash); + let partial_ancestor = BlockRef { + parent_hash: None, + timestamp: None, + ..ancestor + }; + let initial = CanonicalSequenceState::new(vec![ancestor, old_tip], Some(old_tip), None, None); + let rewind = validate_canonical_sequence( + &initial, + &ReactiveInputBatch::::new(Vec::new()).with_chain_controls([ + ChainControl::Reorg { + common_ancestor: partial_ancestor, + old_tip, + new_tip, + }, + ]), + )?; + assert_eq!(rewind.next_state().coverage_head(), Some(&ancestor)); + assert_eq!( + rewind.mutations(), + &[CanonicalSequenceMutation::Rewind { + common_ancestor: Some(ancestor), + dropped: vec![old_tip], + }] + ); + assert_eq!( + replay_sequence_mutations(&initial, rewind.mutations()), + *rewind.next_state() + ); + + let coverage = block(41, B256::repeat_byte(0x41), B256::repeat_byte(0x40)); + let resolved = block(40, coverage.parent_hash.unwrap(), B256::repeat_byte(0x3f)); + let partial = BlockRef { + parent_hash: None, + timestamp: None, + ..resolved + }; + let finality_state = CanonicalSequenceState::new( + vec![resolved, coverage], + Some(coverage), + Some(resolved), + Some(resolved), + ); + let finality = validate_canonical_sequence( + &finality_state, + &ReactiveInputBatch::::new(Vec::new()).with_chain_controls([ + ChainControl::Safe(partial), + ChainControl::Finalized(partial), + ]), + )?; + assert_eq!( + finality.mutations(), + &[ + CanonicalSequenceMutation::Safe(resolved), + CanonicalSequenceMutation::Finalized(resolved), + ] + ); + assert_eq!( + replay_sequence_mutations(&finality_state, finality.mutations()), + *finality.next_state() + ); + + let conflicting = BlockRef { + timestamp: resolved.timestamp.map(|timestamp| timestamp + 1), + ..resolved + }; + let sparse_finality_state = CanonicalSequenceState::new( + vec![coverage], + Some(coverage), + Some(resolved), + Some(resolved), + ); + for control in [ + ChainControl::Safe(conflicting), + ChainControl::Finalized(conflicting), + ] { + assert!(matches!( + validate_canonical_sequence( + &sparse_finality_state, + &ReactiveInputBatch::::new(Vec::new()).with_chain_controls([control]), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); + } + Ok(()) +} + +#[test] +fn explicit_reorg_old_tip_metadata_must_match_current_coverage() { + let ancestor = block(50, B256::repeat_byte(0x50), B256::repeat_byte(0x49)); + let old_tip = block(51, B256::repeat_byte(0x51), ancestor.hash); + let conflicting_old_tip = BlockRef { + timestamp: old_tip.timestamp.map(|timestamp| timestamp + 1), + ..old_tip + }; + let new_tip = block(51, B256::repeat_byte(0xd1), ancestor.hash); + let state = CanonicalSequenceState::new(vec![ancestor, old_tip], Some(old_tip), None, None); + + assert!(matches!( + validate_canonical_sequence( + &state, + &ReactiveInputBatch::::new(Vec::new()).with_chain_controls([ + ChainControl::Reorg { + common_ancestor: ancestor, + old_tip: conflicting_old_tip, + new_tip, + }, + ]), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let sparse_ancestor = block(60, B256::repeat_byte(0x60), B256::repeat_byte(0x59)); + let known_wrong_height = block(61, B256::repeat_byte(0x61), sparse_ancestor.hash); + let sparse_old_tip = block(63, B256::repeat_byte(0x63), B256::repeat_byte(0x62)); + let sparse_new_tip = block(63, B256::repeat_byte(0xe3), known_wrong_height.hash); + let sparse_state = CanonicalSequenceState::new( + vec![sparse_ancestor, known_wrong_height, sparse_old_tip], + Some(sparse_old_tip), + None, + None, + ); + assert!(matches!( + validate_canonical_sequence( + &sparse_state, + &ReactiveInputBatch::::new(Vec::new()).with_chain_controls([ + ChainControl::Reorg { + common_ancestor: sparse_ancestor, + old_tip: sparse_old_tip, + new_tip: sparse_new_tip, + }, + ]), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); +} + +#[test] +fn explicit_reorg_rejects_tip_identities_that_disappear_after_rewind() { + let common_ancestor = block(18, B256::repeat_byte(0x18), B256::repeat_byte(0x17)); + let exact_parent = block(19, B256::repeat_byte(0x19), common_ancestor.hash); + let partial_old_tip = BlockRef { + number: 20, + hash: B256::repeat_byte(0x20), + parent_hash: None, + timestamp: Some(1_700_000_020), + }; + let state = CanonicalSequenceState::new( + vec![common_ancestor, exact_parent, partial_old_tip], + Some(partial_old_tip), + None, + None, + ); + let conflicting_old_tip = BlockRef { + parent_hash: Some(B256::repeat_byte(0xfe)), + ..partial_old_tip + }; + let invalid_old_tip = validate_canonical_sequence_diagnostic( + &state, + &ReactiveInputBatch::::new(Vec::new()).with_chain_controls([ + ChainControl::Reorg { + common_ancestor, + old_tip: conflicting_old_tip, + new_tip: block(20, B256::repeat_byte(0xf0), B256::repeat_byte(0xef)), + }, + ]), + ) + .expect_err("a dropped old tip cannot contradict its retained predecessor"); + assert!(!invalid_old_tip.requires_history()); + assert!(matches!( + invalid_old_tip, + CanonicalSequenceError::Invalid(ReactiveError::InvalidChainControl { .. }) + )); + + let reused_hash = block(17, B256::repeat_byte(0x77), B256::repeat_byte(0x16)); + let ancestor = block(18, B256::repeat_byte(0x78), reused_hash.hash); + let old_tip = block(19, B256::repeat_byte(0x79), ancestor.hash); + let reused_state = CanonicalSequenceState::new( + vec![reused_hash, ancestor, old_tip], + Some(old_tip), + None, + None, + ); + let invalid_new_tip = validate_canonical_sequence_diagnostic( + &reused_state, + &ReactiveInputBatch::::new(Vec::new()).with_chain_controls([ + ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip: block(19, reused_hash.hash, ancestor.hash), + }, + ]), + ) + .expect_err("a new tip cannot reuse a retained hash from another height"); + assert!(!invalid_new_tip.requires_history()); + assert!(matches!( + invalid_new_tip, + CanonicalSequenceError::Invalid(ReactiveError::InvalidChainControl { .. }) + )); +} + +#[test] +fn sparse_explicit_reorg_ancestor_must_match_the_retained_old_branch() { + let oldest = block(100, B256::repeat_byte(0x64), B256::repeat_byte(0x63)); + let ancestor = block(103, B256::repeat_byte(0x67), B256::repeat_byte(0x66)); + let retained_child = block(104, B256::repeat_byte(0x68), B256::repeat_byte(0xba)); + let old_tip = block(105, B256::repeat_byte(0x69), retained_child.hash); + let new_tip = block(105, B256::repeat_byte(0xf5), B256::repeat_byte(0xf4)); + let conflicting_child_state = CanonicalSequenceState::new( + vec![oldest, retained_child, old_tip], + Some(old_tip), + None, + None, + ); + let non_adjacent_old_tip = BlockRef { + parent_hash: Some(ancestor.hash), + ..old_tip + }; + let non_adjacent_state = CanonicalSequenceState::new( + vec![oldest, non_adjacent_old_tip], + Some(non_adjacent_old_tip), + None, + None, + ); + + for (state, exact_old_tip) in [ + (conflicting_child_state, old_tip), + (non_adjacent_state, non_adjacent_old_tip), + ] { + let error = validate_canonical_sequence_diagnostic( + &state, + &ReactiveInputBatch::::new(Vec::new()).with_chain_controls([ + ChainControl::Reorg { + common_ancestor: ancestor, + old_tip: exact_old_tip, + new_tip, + }, + ]), + ) + .expect_err("the declared ancestor must agree with the retained old branch"); + assert!(!error.requires_history()); + assert!(matches!( + error, + CanonicalSequenceError::Invalid(ReactiveError::InvalidChainControl { .. }) + )); + } +} + +#[test] +fn explicit_input_identity_parts_enforce_object_representation_pairs() { + let log_ref = InputRef::Log { + chain_id: Some(1), + block_hash: B256::repeat_byte(1), + transaction_hash: B256::repeat_byte(2), + log_index: 3, + }; + let identity = + ReactiveInputIdentity::try_from_parts(log_ref, ReactiveInputKind::ReorgSignalLog) + .expect("a reorg signal is a valid log representation"); + assert_eq!(identity.input_ref(), log_ref); + assert_eq!(identity.kind(), ReactiveInputKind::ReorgSignalLog); + + let error = ReactiveInputIdentity::try_from_parts(log_ref, ReactiveInputKind::FullBlock) + .expect_err("a log reference cannot identify a full block representation"); + assert_eq!(error.input_ref(), log_ref); + assert_eq!(error.kind(), ReactiveInputKind::FullBlock); +} + +#[tokio::test] +async fn every_reorg_signal_is_control_only_even_when_nothing_can_be_rolled_back() -> Result<()> { + let address = Address::repeat_byte(0x34); + let slot = U256::from(44); + let dropped = block(44, B256::repeat_byte(0x44), B256::repeat_byte(0x43)); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, address); + cache + .db_mut() + .insert_account_storage(address, slot, U256::from(7))?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(SlotWriter { + id: HandlerId::new("must-not-run-on-reorg"), + address, + slot, + value: U256::from(99), + }))?; + + let removed = || { + ReactiveInputRecord::new( + ReactiveInput::::Log(rpc_log( + address, + vec![keccak256(b"Removed()")], + &dropped, + 0, + 0, + true, + )), + reorged_context(dropped, 0), + ) + }; + + // Unknown/deep and repeated removals have no resident journal entry. They + // are still lifecycle controls, never ordinary handler inputs. + for record in [removed(), removed()] { + runtime.ingest_batch(&mut cache, ReactiveInputBatch::new(vec![record]))?; + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(7)) + ); + } + + // Routing scope cannot turn a removal back into data. In particular, an + // owner catch-up source must not replay a removed log into its handler. + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![removed()]) + .with_audience(DeliveryAudience::Owners(vec![HandlerId::new( + "must-not-run-on-reorg", + )])) + .with_delivery_scope(DeliveryScope::OwnerCatchup), + )?; + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(7)) + ); + Ok(()) +} + +#[tokio::test] +async fn same_batch_removed_old_branch_is_applied_before_its_replacement() -> Result<()> { + let address = Address::repeat_byte(0x36); + let slot = U256::from(36); + let parent = block(79, B256::repeat_byte(0x79), B256::repeat_byte(0x78)); + let old = block(80, B256::repeat_byte(0x80), parent.hash); + let replacement = block(80, B256::repeat_byte(0x81), parent.hash); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(LogIndexSlotWriter { address, slot }))?; + + for (canonical, value) in [(&parent, 10), (&old, 20)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Branch(uint256)")], + canonical, + 0, + value, + false, + )), + included_context(*canonical, value), + ), + )?; + } + + let replacement_record = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Branch(uint256)")], + &replacement, + 0, + 30, + false, + )), + included_context(replacement, 30), + ); + let removed_record = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Branch(uint256)")], + &old, + 0, + 20, + true, + )), + reorged_context(old, 20), + ); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![replacement_record, removed_record]), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(replacement)); + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(30)), + "the old-branch lifecycle signal must run before replacement data" + ); + Ok(()) +} + +#[tokio::test] +async fn distinct_same_batch_removals_share_one_rollback_without_losing_lifecycle_inputs() +-> Result<()> { + let address = Address::repeat_byte(0x3a); + let slot = U256::from(38); + let block_10 = block(10, B256::repeat_byte(0x10), B256::repeat_byte(0x09)); + let block_11 = block(11, B256::repeat_byte(0x11), block_10.hash); + let block_12 = block(12, B256::repeat_byte(0x12), block_11.hash); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(LogIndexSlotWriter { address, slot }))?; + + for (canonical, value) in [(&block_10, 10), (&block_11, 11), (&block_12, 12)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Canonical(uint256)")], + canonical, + 0, + value, + false, + )), + included_context(*canonical, value), + ), + )?; + } + + let removed = |dropped: BlockRef, log_index: u64| { + ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Removed(uint256)")], + &dropped, + 0, + log_index, + true, + )), + reorged_context(dropped, log_index), + ) + }; + let report = runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ + removed(block_11, 0), + removed(block_11, 1), + removed(block_12, 2), + ]), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(block_10)); + assert_eq!(runtime.metrics().deep_reorgs, 0); + assert_eq!( + runtime.health(), + evm_fork_cache::reactive::CacheHealth::Healthy + ); + assert_eq!( + report + .reports + .iter() + .filter(|report| matches!(report.as_ref(), ReactiveReport::Input(_))) + .count(), + 3, + "every distinct removed lifecycle record remains observable" + ); + assert_eq!( + report + .reports + .iter() + .filter(|report| matches!(report.as_ref(), ReactiveReport::Reorg(_))) + .count(), + 1, + "one rollback must cover all later removals from the drained span" + ); + Ok(()) +} + +#[tokio::test] +async fn explicit_reorg_coalesces_redundant_removed_records_with_and_without_replacement() +-> Result<()> { + for include_replacement in [false, true] { + let address = Address::repeat_byte(if include_replacement { 0x3c } else { 0x3b }); + let slot = U256::from(39); + let ancestor = block(20, B256::repeat_byte(0x20), B256::repeat_byte(0x19)); + let old_tip = block(21, B256::repeat_byte(0x21), ancestor.hash); + let replacement = block(21, B256::repeat_byte(0xa1), ancestor.hash); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(LogIndexSlotWriter { address, slot }))?; + for (canonical, value) in [(&ancestor, 20), (&old_tip, 21)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Explicit(uint256)")], + canonical, + 0, + value, + false, + )), + included_context(*canonical, value), + ), + )?; + } + + let mut records = vec![ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"RemovedAfterExplicit()")], + &old_tip, + 0, + 7, + true, + )), + reorged_context(old_tip, 7), + )]; + if include_replacement { + records.push(ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Explicit(uint256)")], + &replacement, + 0, + 31, + false, + )), + included_context(replacement, 31), + )); + } + let report = runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(records) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip: replacement, + }]), + )?; + + assert_eq!( + runtime.last_canonical_block(), + Some(if include_replacement { + replacement + } else { + ancestor + }) + ); + assert_eq!(runtime.metrics().deep_reorgs, 0); + assert_eq!( + runtime.health(), + evm_fork_cache::reactive::CacheHealth::Healthy + ); + assert_eq!( + report + .reports + .iter() + .filter(|report| matches!(report.as_ref(), ReactiveReport::Input(_))) + .count(), + records_len(include_replacement), + ); + } + Ok(()) +} + +const fn records_len(include_replacement: bool) -> usize { + if include_replacement { 2 } else { 1 } +} + +#[tokio::test] +async fn explicit_multiblock_reorg_coalesces_exact_intermediate_removed_logs() -> Result<()> { + let address = Address::repeat_byte(0xb5); + let ancestor = block(10, B256::repeat_byte(0x10), B256::repeat_byte(0x09)); + let old_11 = block(11, B256::repeat_byte(0x11), ancestor.hash); + let old_tip = block(12, B256::repeat_byte(0x12), old_11.hash); + let new_tip = block(12, B256::repeat_byte(0xb2), B256::repeat_byte(0xb1)); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + for (canonical, log_index) in [(ancestor, 0), (old_11, 1), (old_tip, 2)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"ExplicitIntermediateRemoval()")], + &canonical, + 0, + log_index, + false, + )), + included_context(canonical, log_index), + ), + )?; + } + + let report = runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![sequence_log_record(old_11, 3, true)]) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip, + }]), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(ancestor)); + assert_eq!(runtime.metrics().deep_reorgs, 0); + assert_eq!(runtime.metrics().reorgs_recovered, 1); + assert_eq!( + report + .reports + .iter() + .filter(|report| matches!(report.as_ref(), ReactiveReport::Reorg(_))) + .count(), + 1, + "the intermediate removed log is observable as input but does not rerun recovery" + ); + Ok(()) +} + +#[tokio::test] +async fn removed_sole_tip_can_be_replaced_by_zero_event_progress_or_barrier() -> Result<()> { + for use_barrier in [false, true] { + let address = Address::repeat_byte(if use_barrier { 0x3e } else { 0x3d }); + let dropped = block(1, B256::repeat_byte(0x01), B256::ZERO); + let replacement = block(1, B256::repeat_byte(0xf1), B256::ZERO); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig { + journal_depth: 1, + ..ReactiveConfig::default() + }); + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Tip()")], + &dropped, + 0, + 0, + false, + )), + included_context(dropped, 0), + ), + )?; + let removed = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Tip()")], + &dropped, + 0, + 0, + true, + )), + reorged_context(dropped, 0), + ); + let progress = if use_barrier { + ChainControl::Barrier { + id: b"zero-event-replacement".to_vec(), + block: Some(replacement), + } + } else { + ChainControl::CanonicalProgress(replacement) + }; + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![removed]) + .with_chain_id(1) + .with_chain_controls([progress]), + )?; + assert_eq!(runtime.last_canonical_block(), Some(replacement)); + assert_eq!(runtime.metrics().deep_reorgs, 0); + } + Ok(()) +} + +#[test] +fn zero_event_replacement_rejects_unverifiable_outside_window_conflicting_and_finalized_paths() { + let address = Address::repeat_byte(0x3f); + let dropped = block(1, B256::repeat_byte(0x01), B256::ZERO); + let replacement = block(1, B256::repeat_byte(0xf1), B256::ZERO); + let removed = |block: BlockRef| { + ReactiveInputRecord::new( + ReactiveInput::::Log(rpc_log( + address, + vec![keccak256(b"Tip()")], + &block, + 0, + 0, + true, + )), + reorged_context(block, 0), + ) + }; + let replacement_batch = |record, controls| { + ReactiveInputBatch::new(vec![record]) + .with_chain_id(1) + .with_chain_controls(controls) + }; + + let unverifiable = BlockRef { + parent_hash: None, + ..dropped + }; + let state = CanonicalSequenceState::new(vec![unverifiable], Some(unverifiable), None, None); + assert!(matches!( + validate_canonical_sequence( + &state, + &replacement_batch( + removed(unverifiable), + vec![ChainControl::CanonicalProgress(replacement)], + ), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let retained_tip = block(2, B256::repeat_byte(0x02), dropped.hash); + let outside_window = + CanonicalSequenceState::new(vec![retained_tip], Some(retained_tip), None, None); + assert!(matches!( + validate_canonical_sequence( + &outside_window, + &replacement_batch( + removed(dropped), + vec![ChainControl::CanonicalProgress(replacement)], + ), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let retained = CanonicalSequenceState::new(vec![dropped], Some(dropped), None, None); + let removed_only = ReactiveInputBatch::new(vec![removed(dropped)]); + let removed_only = validate_canonical_sequence(&retained, &removed_only) + .expect("the removed block authenticates its exact parent as new coverage"); + assert_eq!( + removed_only.next_state().coverage_head(), + Some(&BlockRef { + number: 0, + hash: dropped.parent_hash.unwrap(), + parent_hash: None, + timestamp: None, + }) + ); + let conflicting = BlockRef { + hash: B256::repeat_byte(0xf2), + ..replacement + }; + assert!(matches!( + validate_canonical_sequence( + &retained, + &replacement_batch( + removed(dropped), + vec![ + ChainControl::CanonicalProgress(replacement), + ChainControl::CanonicalProgress(conflicting), + ], + ), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); + + let finalized = + CanonicalSequenceState::new(vec![dropped], Some(dropped), Some(dropped), Some(dropped)); + assert!(matches!( + validate_canonical_sequence( + &finalized, + &replacement_batch( + removed(dropped), + vec![ChainControl::CanonicalProgress(replacement)], + ), + ), + Err(ReactiveError::InvalidChainControl { .. }) + )); +} + +#[tokio::test] +async fn delayed_duplicate_removed_log_cannot_drain_a_different_canonical_hash() -> Result<()> { + let address = Address::repeat_byte(0x37); + let slot = U256::from(37); + let parent = block(79, B256::repeat_byte(0x79), B256::repeat_byte(0x78)); + let old = block(80, B256::repeat_byte(0x80), parent.hash); + let replacement = block(80, B256::repeat_byte(0x81), parent.hash); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(LogIndexSlotWriter { address, slot }))?; + + for (canonical, value) in [(&parent, 10), (&old, 20), (&replacement, 30)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Delayed(uint256)")], + canonical, + 0, + value, + false, + )), + included_context(*canonical, value), + ), + )?; + } + + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Delayed(uint256)")], + &old, + 0, + 20, + true, + )), + reorged_context(old, 20), + ), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(replacement)); + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(30)) + ); + Ok(()) +} + +#[tokio::test] +async fn owner_catchup_revalidates_its_journal_entry_at_the_mutation_boundary() -> Result<()> { + let address = Address::repeat_byte(0x38); + let slot = U256::from(38); + let parent = block(79, B256::repeat_byte(0x79), B256::repeat_byte(0x78)); + let old = block(80, B256::repeat_byte(0x80), parent.hash); + let replacement = block(80, B256::repeat_byte(0x81), parent.hash); + let owner = HandlerId::new("log-index-slot-writer"); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(LogIndexSlotWriter { address, slot }))?; + + for (canonical, value) in [(&parent, 10), (&old, 20)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"OwnerRace(uint256)")], + canonical, + 0, + value, + false, + )), + included_context(*canonical, value), + ), + )?; + } + + let replacement_record = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"OwnerRace(uint256)")], + &replacement, + 0, + 30, + false, + )), + included_context(replacement, 30), + ); + let owner_record = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"OwnerRace(uint256)")], + &old, + 0, + 40, + false, + )), + included_context(old, 40), + ); + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::from_deliveries([ + ReactiveInputDelivery::new( + replacement_record, + DeliveryAudience::All, + DeliveryScope::Canonical, + ), + ReactiveInputDelivery::new( + owner_record, + DeliveryAudience::Owners(vec![owner]), + DeliveryScope::OwnerCatchup, + ), + ]), + ) + .expect_err("replacement must not strand an irreversible owner mutation"); + + assert!(matches!( + error, + ReactiveError::OwnerCatchupOutsideJournal { + number: 80, + hash, + } if hash == old.hash + )); + assert_eq!(runtime.last_canonical_block(), Some(old)); + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(20)), + "the complete mixed batch must roll back transactionally" + ); + Ok(()) +} + +#[tokio::test] +async fn canonical_delivery_continues_from_barrier_coverage_without_false_gap() -> Result<()> { + let address = Address::repeat_byte(0x35); + let slot = U256::from(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, address); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(SlotWriter { + id: HandlerId::new("barrier-continuity"), + address, + slot, + value: U256::from(1), + }))?; + + let block_90 = block(90, B256::repeat_byte(90), B256::repeat_byte(89)); + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Event()")], + &block_90, + 0, + 0, + false, + )), + included_context(block_90, 0), + ), + )?; + let block_100 = block(100, B256::repeat_byte(100), B256::repeat_byte(99)); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Barrier { + id: b"covered-through-100".to_vec(), + block: Some(block_100), + }]), + )?; + + let block_101 = block(101, B256::repeat_byte(101), block_100.hash); + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Event()")], + &block_101, + 0, + 0, + false, + )), + included_context(block_101, 0), + ), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(block_101)); + assert_eq!(runtime.metrics().missed_ranges, 0); + assert_eq!( + runtime.health(), + evm_fork_cache::reactive::CacheHealth::Healthy + ); + Ok(()) +} + +#[tokio::test] +async fn compact_canonical_progress_advances_coverage_without_a_fabricated_header() -> Result<()> { + let mut cache = setup_cache().await?; + cache.set_block_context(Some(199), Some(7)); + cache.set_coinbase(Some(Address::repeat_byte(0xcc))); + cache.set_prevrandao(Some(B256::repeat_byte(0xdd))); + cache.set_block_gas_limit(Some(30_000_000)); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + let progress = block(200, B256::repeat_byte(200), B256::repeat_byte(199)); + + let report = runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::CanonicalProgress(progress)]), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(progress)); + assert_eq!(cache.block(), BlockId::from((progress.hash, Some(true)))); + assert_eq!(cache.block_number(), Some(progress.number)); + assert_eq!(cache.timestamp(), progress.timestamp); + assert_eq!(cache.basefee(), None); + assert_eq!(cache.coinbase(), None); + assert_eq!(cache.prevrandao(), None); + assert_eq!(cache.block_gas_limit(), None); + assert!(report.applied.is_empty()); + assert!(report.reports.iter().any(|report| matches!( + report.as_ref(), + ReactiveReport::ChainControl(control) + if control.control == ChainControl::CanonicalProgress(progress) + ))); + Ok(()) +} + +#[tokio::test] +async fn certified_sparse_backfill_keeps_zero_event_tail_and_does_not_report_live_gap() -> Result<()> +{ + let address = Address::repeat_byte(0x36); + let slot = U256::from(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, address); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + let owner = HandlerId::new("sparse-backfill"); + runtime.register_handler(Arc::new(SlotWriter { + id: owner.clone(), + address, + slot, + value: U256::from(9), + }))?; + + let baseline = block(100, B256::repeat_byte(100), B256::repeat_byte(99)); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::CanonicalProgress(baseline)]), + )?; + let event_block = block(105, B256::repeat_byte(105), B256::repeat_byte(104)); + let certified_tail = block(110, B256::repeat_byte(110), B256::repeat_byte(109)); + let report = runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Event()")], + &event_block, + 0, + 0, + false, + )), + included_context(event_block, 0), + )]) + .with_delivery_scope(DeliveryScope::CanonicalProgress) + .with_chain_controls([ChainControl::Barrier { + id: b"certified-through-110".to_vec(), + block: Some(certified_tail), + }]), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(certified_tail)); + assert_eq!( + cache.block(), + BlockId::from((certified_tail.hash, Some(true))) + ); + assert_eq!(cache.block_number(), Some(certified_tail.number)); + assert_eq!(cache.timestamp(), certified_tail.timestamp); + assert_eq!(runtime.metrics().missed_ranges, 0); + assert!( + !report + .reports + .iter() + .any(|report| matches!(report.as_ref(), ReactiveReport::Reorg(_))) + ); + + // The zero-log tail is a real retained anchor, so a newly registered + // owner's inclusive catch-up at the certified head remains rollbackable. + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Event()")], + &certified_tail, + 0, + 1, + false, + )), + included_context(certified_tail, 1), + )]) + .with_audience(DeliveryAudience::Owners(vec![owner])) + .with_delivery_scope(DeliveryScope::OwnerCatchup), + )?; + Ok(()) +} + +#[tokio::test] +async fn same_head_partial_log_cannot_downgrade_canonical_metadata() -> Result<()> { + let address = Address::repeat_byte(0x37); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + let full = block(120, B256::repeat_byte(120), B256::repeat_byte(119)); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::CanonicalProgress(full)]), + )?; + + let partial = BlockRef { + parent_hash: None, + timestamp: None, + ..full + }; + let mut log = rpc_log( + address, + vec![keccak256(b"Metadata()")], + &partial, + 0, + 0, + false, + ); + log.block_timestamp = None; + runtime.ingest_batch( + &mut cache, + batch(ReactiveInput::Log(log), included_context(partial, 0)), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(full)); + assert_eq!(cache.timestamp(), full.timestamp); + Ok(()) +} + +#[tokio::test] +async fn control_only_batches_require_the_cache_chain_identity() -> Result<()> { + let mut cache = setup_cache().await?; + let progress = block(200, B256::repeat_byte(200), B256::repeat_byte(199)); + + for batch in [ + ReactiveInputBatch::new(Vec::new()) + .with_chain_controls([ChainControl::CanonicalProgress(progress)]), + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(2) + .with_chain_controls([ChainControl::CanonicalProgress(progress)]), + ] { + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + let error = runtime + .ingest_batch(&mut cache, batch) + .expect_err("unbound or cross-chain controls must fail closed"); + assert!(matches!( + error, + ReactiveError::InvalidChainControl { .. } | ReactiveError::InvalidInputRecord { .. } + )); + assert!(runtime.last_canonical_block().is_none()); + } + Ok(()) +} + +#[tokio::test] +async fn explicit_reorg_rejects_noop_and_same_height_non_descendant_triples() -> Result<()> { + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + let current = block(30, B256::repeat_byte(30), B256::repeat_byte(29)); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::CanonicalProgress(current)]), + )?; + + for new_tip in [ + current, + block(30, B256::repeat_byte(31), B256::repeat_byte(29)), + ] { + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: current, + old_tip: current, + new_tip, + }]), + ) + .expect_err("a reorg must replace a non-empty branch above its ancestor"); + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), Some(current)); + } + Ok(()) } struct SlotWriter { @@ -282,7 +2926,7 @@ async fn reactive_runtime_rolls_back_hash_pinned_resync_effects_with_dropped_blo 0, false, )), - included_context(dropped.clone(), 0), + included_context(dropped, 0), ), )?; assert_eq!( @@ -309,98 +2953,662 @@ async fn reactive_runtime_rolls_back_hash_pinned_resync_effects_with_dropped_blo Some(U256::from(10)), "reorg rollback must unwind authoritative resync writes as well as direct effects" ); - let reorg = report - .reports - .iter() - .find_map(|report| match report.as_ref() { - ReactiveReport::Reorg(report) => Some(report), - _ => None, - }) - .expect("removed log emits a reorg report"); - assert_eq!(reorg.rollback_updates.len(), 2); - assert_eq!(reorg.rollback_diff.slots[0].old, U256::from(42)); - assert_eq!(reorg.rollback_diff.slots[0].new, U256::from(20)); - assert_eq!(reorg.rollback_diff.slots[1].old, U256::from(20)); - assert_eq!(reorg.rollback_diff.slots[1].new, U256::from(10)); + let reorg = report + .reports + .iter() + .find_map(|report| match report.as_ref() { + ReactiveReport::Reorg(report) => Some(report), + _ => None, + }) + .expect("removed log emits a reorg report"); + assert_eq!(reorg.rollback_updates.len(), 2); + assert_eq!(reorg.rollback_diff.slots[0].old, U256::from(42)); + assert_eq!(reorg.rollback_diff.slots[0].new, U256::from(20)); + assert_eq!(reorg.rollback_diff.slots[1].old, U256::from(20)); + assert_eq!(reorg.rollback_diff.slots[1].new, U256::from(10)); + + Ok(()) +} + +#[tokio::test] +async fn reactive_runtime_rolls_back_removed_log_storage_effects() -> Result<()> { + let address = Address::repeat_byte(0xa1); + let slot = U256::from(7); + let dropped = block(70, B256::repeat_byte(0x70), B256::repeat_byte(0x6f)); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, address); + cache + .db_mut() + .insert_account_storage(address, slot, U256::from(10))?; + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(SlotWriter { + id: HandlerId::new("slot-writer"), + address, + slot, + value: U256::from(20), + }))?; + + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Write(uint256)")], + &dropped, + 0, + 0, + false, + )), + included_context(dropped, 0), + ), + )?; + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(20)) + ); + + let report = runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Write(uint256)")], + &dropped, + 0, + 0, + true, + )), + reorged_context(dropped, 0), + ), + )?; + + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(10)), + "removed logs should roll back reversible storage writes" + ); + let reorg = report + .reports + .iter() + .find_map(|report| match report.as_ref() { + ReactiveReport::Reorg(report) => Some(report), + _ => None, + }) + .expect("removed log emits a reorg report"); + assert_eq!(reorg.dropped_blocks, vec![dropped]); + assert_eq!(reorg.rollback_updates.len(), 1); + assert!(reorg.purge_updates.is_empty()); + assert_eq!(reorg.rollback_diff.slots[0].old, U256::from(20)); + assert_eq!(reorg.rollback_diff.slots[0].new, U256::from(10)); + + Ok(()) +} + +#[tokio::test] +async fn explicit_chain_controls_are_ordered_with_delivery_and_rollback_state() -> Result<()> { + let address = Address::repeat_byte(0xa9); + let slot = U256::from(19); + let ancestor = block(90, B256::repeat_byte(0x90), B256::repeat_byte(0x89)); + let old_tip = block(91, B256::repeat_byte(0x91), ancestor.hash); + let new_tip = block(91, B256::repeat_byte(0xa1), ancestor.hash); + let safe = block(89, B256::repeat_byte(0x89), B256::repeat_byte(0x88)); + let finalized = block(88, B256::repeat_byte(0x88), B256::repeat_byte(0x87)); + let mut cache = setup_cache().await?; + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(LogIndexSlotWriter { address, slot }))?; + for (block, value) in [(&ancestor, 20), (&old_tip, 30)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Controlled(uint256)")], + block, + 0, + value, + false, + )), + included_context(*block, value), + ), + )?; + } + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(30)) + ); + + let report = runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip, + }, + ChainControl::Safe(safe), + ChainControl::Finalized(finalized), + ChainControl::Barrier { + id: b"catchup-complete".to_vec(), + block: Some(ancestor), + }, + ]), + )?; + + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(20)) + ); + assert_eq!(runtime.last_canonical_block(), Some(ancestor)); + assert_eq!(runtime.safe_head(), Some(&safe)); + assert_eq!(runtime.finalized_head(), Some(&finalized)); + assert!(report.reports.iter().any(|report| matches!( + report.as_ref(), + ReactiveReport::Reorg(reorg) + if reorg.reason == evm_fork_cache::reactive::ReorgReason::Explicit + && reorg.dropped_blocks == vec![old_tip] + ))); + let controls: Vec<_> = report + .reports + .iter() + .filter_map(|report| match report.as_ref() { + ReactiveReport::ChainControl(report) => Some(report.control.clone()), + _ => None, + }) + .collect(); + assert_eq!( + controls, + vec![ + ChainControl::Reorg { + common_ancestor: block(90, B256::repeat_byte(0x90), B256::repeat_byte(0x89),), + old_tip, + new_tip, + }, + ChainControl::Safe(safe), + ChainControl::Finalized(finalized), + ChainControl::Barrier { + id: b"catchup-complete".to_vec(), + block: Some(block(90, B256::repeat_byte(0x90), B256::repeat_byte(0x89),)), + }, + ] + ); + + Ok(()) +} + +#[tokio::test] +async fn reorg_ancestor_in_zero_event_gap_rolls_back_without_false_deep_reorg() -> Result<()> { + let address = Address::repeat_byte(0xb1); + let slot = U256::from(23); + let retained = block(100, B256::repeat_byte(0x64), B256::repeat_byte(0x63)); + let old_tip = block(105, B256::repeat_byte(0x69), B256::repeat_byte(0x68)); + let ancestor = block(103, B256::repeat_byte(0x67), B256::repeat_byte(0x66)); + let new_tip = block(105, B256::repeat_byte(0xf5), B256::repeat_byte(0xf4)); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(LogIndexSlotWriter { address, slot }))?; + + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Gap(uint256)")], + &retained, + 0, + 20, + false, + )), + included_context(retained, 20), + ), + )?; + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Gap(uint256)")], + &old_tip, + 0, + 30, + false, + )), + included_context(old_tip, 30), + )]) + .with_delivery_scope(DeliveryScope::CanonicalProgress) + .with_chain_controls([ChainControl::Barrier { + id: b"sparse-old-tip".to_vec(), + block: Some(old_tip), + }]), + )?; + cache.with_blockchain_db_mut(|database| { + database + .block_hashes() + .write() + .insert(U256::from(old_tip.number), old_tip.hash); + }); + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip, + }]), + )?; + + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(20)) + ); + assert_eq!(runtime.last_canonical_block(), Some(ancestor)); + assert_eq!(runtime.metrics().deep_reorgs, 0); + assert_eq!( + runtime.health(), + evm_fork_cache::reactive::CacheHealth::Healthy + ); + assert!( + cache + .unchecked_blockchain_db() + .block_hashes() + .read() + .get(&U256::from(old_tip.number)) + .is_none(), + "a re-pin must not retain BLOCKHASH values from the displaced branch" + ); + Ok(()) +} + +#[tokio::test] +async fn reorg_below_oldest_retained_entry_remains_observable_as_deep() -> Result<()> { + let address = Address::repeat_byte(0xb2); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig { + journal_depth: 2, + ..ReactiveConfig::default() + }); + let block_104 = block(104, B256::repeat_byte(0x68), B256::repeat_byte(0x67)); + let old_tip = block(105, B256::repeat_byte(0x69), block_104.hash); + for current in [block_104, old_tip] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Deep()")], + ¤t, + 0, + 0, + false, + )), + included_context(current, 0), + ), + )?; + } + let ancestor = block(103, B256::repeat_byte(0x67), B256::repeat_byte(0x66)); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip: block(105, B256::repeat_byte(0xf5), B256::repeat_byte(0xf4)), + }]), + )?; + assert_eq!(runtime.metrics().deep_reorgs, 1); + assert!(matches!( + runtime.health(), + evm_fork_cache::reactive::CacheHealth::Degraded { since_block: 104 } + )); Ok(()) } #[tokio::test] -async fn reactive_runtime_rolls_back_removed_log_storage_effects() -> Result<()> { - let address = Address::repeat_byte(0xa1); - let slot = U256::from(7); - let dropped = block(70, B256::repeat_byte(0x70), B256::repeat_byte(0x6f)); +async fn contradictory_chain_controls_fail_before_mutating_runtime_state() -> Result<()> { + let address = Address::repeat_byte(0xaa); + let slot = U256::from(21); + let handler_id = HandlerId::new("atomic-control-writer"); + let tip = block(95, B256::repeat_byte(0x95), B256::repeat_byte(0x94)); + let finalized = block(94, B256::repeat_byte(0x94), B256::repeat_byte(0x93)); let mut cache = setup_cache().await?; install_mock_erc20(&mut cache, address); cache .db_mut() .insert_account_storage(address, slot, U256::from(10))?; - let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); runtime.register_handler(Arc::new(SlotWriter { - id: HandlerId::new("slot-writer"), + id: handler_id.clone(), address, slot, value: U256::from(20), }))?; - runtime.ingest_batch( &mut cache, batch( ReactiveInput::Log(rpc_log( address, - vec![keccak256(b"Write(uint256)")], - &dropped, + vec![keccak256(b"Tip()")], + &tip, 0, 0, false, )), - included_context(dropped.clone(), 0), + included_context(tip, 0), ), )?; + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Finalized(finalized)]), + )?; + + let cache_before = cache.cached_storage_value(address, slot); + let coverage_before = runtime.last_canonical_block(); + let safe_before = runtime.safe_head().cloned(); + let finalized_before = runtime.finalized_head().cloned(); + let health_before = runtime.health(); + let metrics_before = runtime.metrics(); + let resyncs_before = runtime.pending_resyncs().to_vec(); + let journaled_before = runtime.has_journaled_handler_effects(&handler_id); + + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::Reorg { + common_ancestor: finalized, + old_tip: tip, + new_tip: block(95, B256::repeat_byte(0xa5), finalized.hash), + }, + ChainControl::Finalized(block( + 93, + B256::repeat_byte(0x93), + B256::repeat_byte(0x92), + )), + ]), + ) + .expect_err("a later invalid control must reject the whole batch"); + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(cache.cached_storage_value(address, slot), cache_before); + assert_eq!(runtime.last_canonical_block(), coverage_before); + assert_eq!(runtime.safe_head(), safe_before.as_ref()); + assert_eq!(runtime.finalized_head(), finalized_before.as_ref()); + assert_eq!(runtime.health(), health_before); + assert_eq!(runtime.metrics(), metrics_before); + assert_eq!(runtime.pending_resyncs(), resyncs_before); assert_eq!( - cache.cached_storage_value(address, slot), - Some(U256::from(20)) + runtime.has_journaled_handler_effects(&handler_id), + journaled_before ); - let report = runtime.ingest_batch( - &mut cache, - batch( - ReactiveInput::Log(rpc_log( - address, - vec![keccak256(b"Write(uint256)")], - &dropped, - 0, - 0, - true, - )), - reorged_context(dropped.clone(), 0), - ), - )?; + let barrier = block(100, B256::repeat_byte(0x10), B256::repeat_byte(0x99)); + let conflicting_finality = block(100, B256::repeat_byte(0x20), B256::repeat_byte(0x99)); + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::Barrier { + id: b"coverage-100".to_vec(), + block: Some(barrier), + }, + ChainControl::Finalized(conflicting_finality), + ]), + ) + .expect_err("finality must agree with coverage advanced earlier in the batch"); + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), coverage_before); + assert_eq!(runtime.finalized_head(), finalized_before.as_ref()); + + let mismatched_old_tip = block(95, B256::repeat_byte(0xe5), finalized.hash); + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: finalized, + old_tip: mismatched_old_tip, + new_tip: block(95, B256::repeat_byte(0xf5), finalized.hash), + }]), + ) + .expect_err("mismatched old tip must fail closed"); + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), Some(tip)); + assert_eq!(runtime.finalized_head(), Some(&finalized)); + + let regression = block(93, B256::repeat_byte(0x93), B256::repeat_byte(0x92)); + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Finalized(regression)]), + ) + .expect_err("finality regression must fail closed"); + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.finalized_head(), Some(&finalized)); + + let conflicting_tip = block(95, B256::repeat_byte(0xc5), finalized.hash); + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Finalized(conflicting_tip)]), + ) + .expect_err("finality must agree with a known canonical block hash"); + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + + let bad_parent = block(95, tip.hash, B256::repeat_byte(0xee)); + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Safe(bad_parent)]), + ) + .expect_err("an adjacent safe head must descend from finalized"); + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), Some(tip)); + assert_eq!(runtime.finalized_head(), Some(&finalized)); + + Ok(()) +} + +#[tokio::test] +async fn same_hash_control_metadata_conflict_fails_before_mutation() -> Result<()> { + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + let first = block(96, B256::repeat_byte(0x96), B256::repeat_byte(0x95)); + let conflicting = BlockRef { + timestamp: first.timestamp.map(|timestamp| timestamp + 1), + ..first + }; + let cache_generation = cache.snapshot_generation(); + + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::CanonicalProgress(first), + ChainControl::Barrier { + id: b"conflicting-metadata".to_vec(), + block: Some(conflicting), + }, + ]), + ) + .expect_err("same hash with conflicting timestamp must fail closed"); + + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), None); + assert_eq!(cache.snapshot_generation(), cache_generation); + Ok(()) +} + +#[tokio::test] +async fn chain_controls_and_records_are_validated_as_one_atomic_sequence() -> Result<()> { + let address = Address::repeat_byte(0xac); + let slot = U256::from(23); + let certified = block(100, B256::repeat_byte(0x10), B256::repeat_byte(0x09)); + let conflicting = block(100, B256::repeat_byte(0x20), B256::repeat_byte(0x09)); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, address); + cache + .db_mut() + .insert_account_storage(address, slot, U256::from(10))?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(SlotWriter { + id: HandlerId::new("joint-preflight-writer"), + address, + slot, + value: U256::from(20), + }))?; + let record = ReactiveInputRecord::new( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Conflict()")], + &conflicting, + 0, + 0, + false, + )), + included_context(conflicting, 0), + ); + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![record]).with_chain_controls([ChainControl::Barrier { + id: b"certified-100".to_vec(), + block: Some(certified), + }]), + ) + .expect_err("a record cannot contradict an earlier control in the same batch"); + + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), None); assert_eq!( cache.cached_storage_value(address, slot), - Some(U256::from(10)), - "removed logs should roll back reversible storage writes" + Some(U256::from(10)) ); - let reorg = report - .reports - .iter() - .find_map(|report| match report.as_ref() { - ReactiveReport::Reorg(report) => Some(report), - _ => None, - }) - .expect("removed log emits a reorg report"); - assert_eq!(reorg.dropped_blocks, vec![dropped]); - assert_eq!(reorg.rollback_updates.len(), 1); - assert!(reorg.purge_updates.is_empty()); - assert_eq!(reorg.rollback_diff.slots[0].old, U256::from(20)); - assert_eq!(reorg.rollback_diff.slots[0].new, U256::from(10)); + assert!(!runtime.has_journaled_handler_effects(&HandlerId::new("joint-preflight-writer"))); + Ok(()) +} + +#[tokio::test] +async fn reorg_progress_then_safe_accepts_the_replacement_branch_in_one_control_batch() -> Result<()> +{ + let address = Address::repeat_byte(0xab); + let ancestor = block(90, B256::repeat_byte(0x90), B256::repeat_byte(0x89)); + let old_tip = block(91, B256::repeat_byte(0x91), ancestor.hash); + let new_tip = block(91, B256::repeat_byte(0xa1), ancestor.hash); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + + for canonical in [&ancestor, &old_tip] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"ReplacementBranch()")], + canonical, + 0, + 0, + false, + )), + included_context(*canonical, 0), + ), + )?; + } + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Finalized(ancestor)]), + )?; + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::Reorg { + common_ancestor: ancestor, + old_tip, + new_tip, + }, + ChainControl::CanonicalProgress(new_tip), + ChainControl::Safe(new_tip), + ]), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(new_tip)); + assert_eq!(runtime.finalized_head(), Some(&ancestor)); + assert_eq!(runtime.safe_head(), Some(&new_tip)); Ok(()) } +#[tokio::test] +async fn explicit_reorg_rejects_a_fabricated_common_ancestor_hash() -> Result<()> { + let address = Address::repeat_byte(0xad); + let ancestor = block(90, B256::repeat_byte(0x90), B256::repeat_byte(0x89)); + let middle = block(91, B256::repeat_byte(0x91), ancestor.hash); + let old_tip = block(92, B256::repeat_byte(0x92), middle.hash); + let fabricated = block(90, B256::repeat_byte(0xf0), ancestor.parent_hash.unwrap()); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + for canonical in [&ancestor, &middle, &old_tip] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Canonical()")], + canonical, + 0, + 0, + false, + )), + included_context(*canonical, 0), + ), + )?; + } + + let error = runtime + .ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Reorg { + common_ancestor: fabricated, + old_tip, + new_tip: block(92, B256::repeat_byte(0xa2), B256::repeat_byte(0xa1)), + }]), + ) + .expect_err("a retained canonical ancestor has one authoritative hash"); + + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), Some(old_tip)); + assert_eq!(runtime.metrics().deep_reorgs, 0); + Ok(()) +} + #[tokio::test] async fn reactive_runtime_reorgs_parent_mismatch_before_replacement_block() -> Result<()> { let address = Address::repeat_byte(0xa2); @@ -423,7 +3631,7 @@ async fn reactive_runtime_reorgs_parent_mismatch_before_replacement_block() -> R 10, false, )), - included_context(parent.clone(), 10), + included_context(parent, 10), ), )?; runtime.ingest_batch( @@ -437,7 +3645,7 @@ async fn reactive_runtime_reorgs_parent_mismatch_before_replacement_block() -> R 20, false, )), - included_context(dropped.clone(), 20), + included_context(dropped, 20), ), )?; assert_eq!( @@ -456,7 +3664,7 @@ async fn reactive_runtime_reorgs_parent_mismatch_before_replacement_block() -> R 30, false, )), - included_context(replacement.clone(), 30), + included_context(replacement, 30), ), )?; @@ -483,6 +3691,462 @@ async fn reactive_runtime_reorgs_parent_mismatch_before_replacement_block() -> R Ok(()) } +#[tokio::test] +async fn implicit_parent_mismatch_cannot_replace_finalized_state() -> Result<()> { + let address = Address::repeat_byte(0xa6); + let slot = U256::from(28); + let parent = block(79, B256::repeat_byte(0x79), B256::repeat_byte(0x78)); + let finalized = block(80, B256::repeat_byte(0x80), parent.hash); + let replacement = block(80, B256::repeat_byte(0x81), parent.hash); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(LogIndexSlotWriter { address, slot }))?; + for (canonical, value) in [(&parent, 10), (&finalized, 20)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"FinalizedBranch(uint256)")], + canonical, + 0, + value, + false, + )), + included_context(*canonical, value), + ), + )?; + } + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::Safe(finalized), + ChainControl::Finalized(finalized), + ]), + )?; + + let error = runtime + .ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"ConflictingBranch(uint256)")], + &replacement, + 0, + 30, + false, + )), + included_context(replacement, 30), + ), + ) + .expect_err("implicit rollback must not cross finalized state"); + + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(20)) + ); + assert_eq!(runtime.last_canonical_block(), Some(finalized)); + assert_eq!(runtime.safe_head(), Some(&finalized)); + assert_eq!(runtime.finalized_head(), Some(&finalized)); + Ok(()) +} + +#[tokio::test] +async fn unknown_parent_replacement_overwrites_the_stale_parent_blockhash() -> Result<()> { + let address = Address::repeat_byte(0xa8); + let old_parent_hash = B256::repeat_byte(0x79); + let stale_grandparent_hash = B256::repeat_byte(0x78); + let replacement_parent_hash = B256::repeat_byte(0xe9); + let old = block(80, B256::repeat_byte(0x80), old_parent_hash); + let replacement = block(80, B256::repeat_byte(0x81), replacement_parent_hash); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"UnknownParent()")], + &old, + 0, + 0, + false, + )), + included_context(old, 0), + ), + )?; + cache.with_blockchain_db_mut(|database| { + let mut hashes = database.block_hashes().write(); + hashes.insert(U256::from(79), old_parent_hash); + hashes.insert(U256::from(78), stale_grandparent_hash); + }); + cache + .db_mut() + .cache + .block_hashes + .insert(U256::from(78), stale_grandparent_hash); + + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"UnknownParent()")], + &replacement, + 0, + 1, + false, + )), + included_context(replacement, 1), + ), + )?; + + assert_eq!(runtime.last_canonical_block(), Some(replacement)); + assert_eq!( + cache + .unchecked_blockchain_db() + .block_hashes() + .read() + .get(&U256::from(79)) + .copied(), + Some(replacement_parent_hash), + "the arriving exact parent identity must replace stale BLOCKHASH(N-1)" + ); + assert!( + cache + .unchecked_blockchain_db() + .block_hashes() + .read() + .get(&U256::from(78)) + .is_none(), + "an unknown parent does not authenticate stale BLOCKHASH(N-2) in the backend layer" + ); + assert!( + !cache + .db_mut() + .cache + .block_hashes + .contains_key(&U256::from(78)), + "an unknown parent does not authenticate stale BLOCKHASH(N-2) in the revm layer" + ); + Ok(()) +} + +#[tokio::test] +async fn direct_runtime_keeps_an_unproven_parent_replacement_observable() -> Result<()> { + let address = Address::repeat_byte(0xaf); + let parent = block(89, B256::repeat_byte(0x89), B256::repeat_byte(0x88)); + let old_tip = block(90, B256::repeat_byte(0x90), parent.hash); + let replacement = block(90, B256::repeat_byte(0xf0), B256::repeat_byte(0xee)); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + + for (canonical, log_index) in [(parent, 0), (old_tip, 1), (replacement, 2)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"ObservableDeepReorg()")], + &canonical, + 0, + log_index, + false, + )), + included_context(canonical, log_index), + ), + )?; + } + + assert_eq!(runtime.last_canonical_block(), Some(replacement)); + assert_eq!(runtime.metrics().deep_reorgs, 1); + assert!(matches!( + runtime.health(), + evm_fork_cache::reactive::CacheHealth::Degraded { since_block: 90 } + )); + + let child = block(91, B256::repeat_byte(0xf1), replacement.hash); + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"ObservableDeepReorg()")], + &child, + 0, + 3, + false, + )), + included_context(child, 3), + ), + )?; + assert_eq!(runtime.last_canonical_block(), Some(child)); + assert_eq!(runtime.metrics().deep_reorgs, 1); + Ok(()) +} + +#[tokio::test] +async fn direct_runtime_keeps_a_parentless_implicit_replacement_observable() -> Result<()> { + let address = Address::repeat_byte(0xb5); + let parent = block(99, B256::repeat_byte(0x99), B256::repeat_byte(0x98)); + let old_tip = block(100, B256::repeat_byte(0x64), parent.hash); + let replacement = BlockRef { + number: old_tip.number, + hash: B256::repeat_byte(0xf4), + parent_hash: None, + timestamp: old_tip.timestamp, + }; + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + + for (canonical, log_index) in [(parent, 0), (old_tip, 1), (replacement, 2)] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"ObservableParentlessDeepReorg()")], + &canonical, + 0, + log_index, + false, + )), + included_context(canonical, log_index), + ), + )?; + } + + assert_eq!(runtime.last_canonical_block(), Some(replacement)); + assert_eq!(runtime.metrics().deep_reorgs, 1); + assert!(matches!( + runtime.health(), + evm_fork_cache::reactive::CacheHealth::Degraded { since_block: 100 } + )); + Ok(()) +} + +#[tokio::test] +async fn empty_journal_discontinuities_still_emit_typed_reorg_reports() -> Result<()> { + let address = Address::repeat_byte(0xb4); + let base = block(80, B256::repeat_byte(0x80), B256::repeat_byte(0x79)); + let first_replacement = block(80, B256::repeat_byte(0xe0), B256::repeat_byte(0xdf)); + let second_replacement = block(80, B256::repeat_byte(0xf0), B256::repeat_byte(0xef)); + let unknown_removed = block(77, B256::repeat_byte(0x77), B256::repeat_byte(0x76)); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig { + journal_depth: 0, + ..ReactiveConfig::default() + }); + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"EmptyJournalReorg()")], + &base, + 0, + 0, + false, + )), + included_context(base, 0), + ), + )?; + + for (replacement, log_index) in [(first_replacement, 1), (second_replacement, 2)] { + let report = runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"EmptyJournalReorg()")], + &replacement, + 0, + log_index, + false, + )), + included_context(replacement, log_index), + ), + )?; + assert!(report.reports.iter().any(|report| { + matches!( + report.as_ref(), + ReactiveReport::Reorg(reorg) + if reorg.reason == evm_fork_cache::reactive::ReorgReason::ParentMismatch + && reorg.dropped_blocks.is_empty() + ) + })); + } + + let removed = runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![ + sequence_log_record(unknown_removed, 3, true), + sequence_log_record(unknown_removed, 4, true), + ]), + )?; + assert_eq!( + removed + .reports + .iter() + .filter( + |report| matches!(report.as_ref(), ReactiveReport::Reorg(reorg) + if reorg.reason == evm_fork_cache::reactive::ReorgReason::RemovedLog + && reorg.dropped == Some(unknown_removed) + && reorg.dropped_blocks.is_empty()) + ) + .count(), + 1, + "per-log removals coalesce only inside one atomic batch" + ); + assert_eq!(runtime.metrics().reorgs_recovered, 3); + assert_eq!(runtime.metrics().deep_reorgs, 3); + assert!(matches!( + runtime.health(), + evm_fork_cache::reactive::CacheHealth::Unhealthy { .. } + )); + Ok(()) +} + +#[tokio::test] +async fn unknown_parent_replacement_is_rejected_when_finalized_descent_is_unproven() -> Result<()> { + let address = Address::repeat_byte(0xa9); + let finalized = block(78, B256::repeat_byte(0x78), B256::repeat_byte(0x77)); + let old_parent = block(79, B256::repeat_byte(0x79), finalized.hash); + let old = block(80, B256::repeat_byte(0x80), old_parent.hash); + let replacement = block(80, B256::repeat_byte(0x81), B256::repeat_byte(0xe9)); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig { + journal_depth: 2, + ..ReactiveConfig::default() + }); + + for canonical in [finalized, old_parent, old] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"FinalizedDescent()")], + &canonical, + 0, + 0, + false, + )), + included_context(canonical, 0), + ), + )?; + } + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ChainControl::Finalized(finalized)]), + )?; + + let generation = cache.snapshot_generation(); + let error = runtime + .ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"FinalizedDescent()")], + &replacement, + 0, + 1, + false, + )), + included_context(replacement, 1), + ), + ) + .expect_err("unknown parent cannot prove descent from finalized state"); + + assert!(matches!(error, ReactiveError::InvalidChainControl { .. })); + assert_eq!(runtime.last_canonical_block(), Some(old)); + assert_eq!(runtime.finalized_head(), Some(&finalized)); + assert_eq!(cache.snapshot_generation(), generation); + Ok(()) +} + +#[tokio::test] +async fn removed_and_reorged_records_cannot_drop_finalized_state() -> Result<()> { + let address = Address::repeat_byte(0xa7); + let parent = block(79, B256::repeat_byte(0x79), B256::repeat_byte(0x78)); + let finalized = block(80, B256::repeat_byte(0x80), parent.hash); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + for canonical in [&parent, &finalized] { + runtime.ingest_batch( + &mut cache, + batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Canonical()")], + canonical, + 0, + 0, + false, + )), + included_context(*canonical, 0), + ), + )?; + } + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(Vec::new()) + .with_chain_id(1) + .with_chain_controls([ + ChainControl::Safe(finalized), + ChainControl::Finalized(finalized), + ]), + )?; + + let removed = batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Canonical()")], + &finalized, + 0, + 0, + true, + )), + reorged_context(finalized, 0), + ); + assert!(matches!( + runtime + .ingest_batch(&mut cache, removed) + .expect_err("removed log cannot cross finality"), + ReactiveError::InvalidChainControl { .. } + )); + + let reorged = batch( + ReactiveInput::Log(rpc_log( + address, + vec![keccak256(b"Canonical()")], + &finalized, + 0, + 0, + false, + )), + reorged_context(finalized, 0), + ); + assert!(matches!( + runtime + .ingest_batch(&mut cache, reorged) + .expect_err("reorged status cannot cross finality"), + ReactiveError::InvalidChainControl { .. } + )); + assert_eq!(runtime.last_canonical_block(), Some(finalized)); + assert_eq!(runtime.safe_head(), Some(&finalized)); + assert_eq!(runtime.finalized_head(), Some(&finalized)); + Ok(()) +} + #[tokio::test] async fn reactive_runtime_falls_back_to_purge_for_irreversible_dropped_effects() -> Result<()> { let address = Address::repeat_byte(0xa3); @@ -509,7 +4173,7 @@ async fn reactive_runtime_falls_back_to_purge_for_irreversible_dropped_effects() 0, false, )), - included_context(dropped.clone(), 0), + included_context(dropped, 0), ), )?; assert_eq!(cache.cached_storage_value(address, slot), Some(U256::ZERO)); @@ -575,7 +4239,7 @@ async fn reactive_runtime_cancels_hash_pinned_resyncs_for_dropped_blocks() -> Re 0, false, )), - included_context(dropped.clone(), 0), + included_context(dropped, 0), ), )?; assert_eq!(first.resyncs.len(), 1); diff --git a/tests/reactive_resync.rs b/tests/reactive_resync.rs index 7803790..f0f3c57 100644 --- a/tests/reactive_resync.rs +++ b/tests/reactive_resync.rs @@ -1,4 +1,4 @@ -//! Manager-authored acceptance tests for reactive resync execution. +//! Acceptance tests for reactive resync execution. //! //! These tests pin the next runtime slice after routing: handlers can already //! emit `ResyncRequest`s, but the runtime must also be able to execute storage @@ -58,7 +58,7 @@ fn included_context(block_number: u64) -> ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -435,7 +435,7 @@ impl ReactiveHandler for AccountOnlyResync { } } -/// WS-1a / Phase-8 s1 (manager-authored red-green): with an `AccountProofFetchFn` +/// WS-1a / Phase-8 s1 red-green coverage: with an `AccountProofFetchFn` /// installed, an `Account`-target resync now SUCCEEDS via the `eth_getProof` seam /// — it no longer fails as `UnsupportedAccountTarget`. The fetched account fields /// are applied through the cache (materialized, so a cold account is not silently diff --git a/tests/reactive_router.rs b/tests/reactive_router.rs index 4e92efc..fdee58d 100644 --- a/tests/reactive_router.rs +++ b/tests/reactive_router.rs @@ -1,4 +1,4 @@ -//! Manager-authored acceptance tests for reactive routing and filter planning. +//! Acceptance tests for reactive routing and filter planning. //! //! These tests pin the public behavior for the provider filter consolidation //! and routing-index phase. They should fail before the router/registry surface @@ -202,7 +202,7 @@ impl LogMatcher for TopicMatcher { } #[test] -fn reactive_registry_consolidates_provider_filters_as_safe_superset() -> Result<()> { +fn reactive_registry_keeps_independent_address_topic_pairs_exact() -> Result<()> { let token_a = Address::repeat_byte(0xa1); let token_b = Address::repeat_byte(0xb2); let sig_a = keccak256(b"TokenAEvent()"); @@ -227,18 +227,23 @@ fn reactive_registry_consolidates_provider_filters_as_safe_superset() -> Result< )))?; let filters = registry.log_subscription_filters(); - assert_eq!(filters.len(), 1, "compatible log interests should merge"); - let consolidated = &filters[0]; + assert_eq!( + filters.len(), + 2, + "address/topic pairs must not merge into a Cartesian-product superset" + ); let wanted_a = rpc_log(token_a, vec![sig_a]); let wanted_b = rpc_log(token_b, vec![sig_b]); let overfetched = rpc_log(token_a, vec![sig_b]); - assert!(consolidated.rpc_matches(&wanted_a)); - assert!(consolidated.rpc_matches(&wanted_b)); + assert!(filters.iter().any(|filter| filter.rpc_matches(&wanted_a))); + assert!(filters.iter().any(|filter| filter.rpc_matches(&wanted_b))); assert!( - consolidated.rpc_matches(&overfetched), - "merged filters may be a safe provider-side superset" + !filters + .iter() + .any(|filter| filter.rpc_matches(&overfetched)), + "subscription planning must not introduce unrelated cross-product logs" ); let route_a = registry.route_log(&wanted_a); diff --git a/tests/reactive_runtime.rs b/tests/reactive_runtime.rs index fb2acf6..d2d2333 100644 --- a/tests/reactive_runtime.rs +++ b/tests/reactive_runtime.rs @@ -1,4 +1,4 @@ -//! Manager-authored acceptance tests for the reactive runtime feature. +//! Acceptance tests for the reactive runtime feature. //! //! These tests intentionally describe the new public contract before the //! implementation exists. They should fail on the current log-only event pipeline @@ -63,7 +63,7 @@ fn included_context(block_number: u64, log_index: u64) -> ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), @@ -164,6 +164,50 @@ struct CountingIndexedHandler { handle_calls: Arc, } +struct FailOnBlockWriter { + address: Address, + slot: U256, + fail_on: u64, +} + +impl ReactiveHandler for FailOnBlockWriter { + fn id(&self) -> HandlerId { + HandlerId::new("transactional-failure") + } + + fn interests(&self) -> Vec { + vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(self.address), + local_matcher: None, + route_key: Some(RouteKeySpec::EmitterAddress), + })] + } + + fn handle( + &self, + ctx: &ReactiveContext, + _input: &ReactiveInput, + _state: &dyn StateView, + ) -> Result { + if ctx + .block + .as_ref() + .is_some_and(|block| block.number == self.fail_on) + { + return Err(HandlerError::new("forced later-record failure")); + } + Ok(HandlerOutcome { + effects: vec![ReactiveEffect::StateUpdate(StateUpdate::slot( + self.address, + self.slot, + U256::from(999), + ))], + quality: StateEffectQuality::ExactFromInput, + tags: Vec::new(), + }) + } +} + impl ReactiveHandler for CountingIndexedHandler { fn id(&self) -> HandlerId { self.id.clone() @@ -248,6 +292,43 @@ async fn reactive_runtime_executes_the_same_indexed_candidates_as_the_registry() Ok(()) } +#[tokio::test] +async fn failed_direct_batch_restores_cache_and_runtime_atomically() -> Result<()> { + let address = Address::repeat_byte(0xdf); + let slot = U256::from(44); + let mut cache = setup_cache().await?; + let _ = cache.apply_update(&StateUpdate::slot(address, slot, U256::from(10))); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(FailOnBlockWriter { + address, + slot, + fail_on: 801, + }))?; + let records = vec![ + ( + ReactiveInput::Log(rpc_log(address, vec![keccak256(b"Event()")], 800, 0, 0)), + included_context(800, 0), + ), + ( + ReactiveInput::Log(rpc_log(address, vec![keccak256(b"Event()")], 801, 0, 0)), + included_context(801, 0), + ), + ]; + + runtime + .ingest_batch(&mut cache, batch(records)) + .expect_err("the later record fails the whole batch"); + + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(10)), + "earlier record writes must be rolled back" + ); + assert!(runtime.last_canonical_block().is_none()); + assert!(!runtime.has_journaled_handler_effects(&HandlerId::new("transactional-failure"))); + Ok(()) +} + struct LogIndexWriter { id: HandlerId, address: Address, diff --git a/tests/reactive_subscriber_ingest.rs b/tests/reactive_subscriber_ingest.rs index 61eef11..43f9ed1 100644 --- a/tests/reactive_subscriber_ingest.rs +++ b/tests/reactive_subscriber_ingest.rs @@ -8,10 +8,11 @@ //! //! It runs offline by fetching the batch through the subscriber's `get_logs` //! backfill path (mockable), which produces exactly the same -//! `ReactiveInputBatch` shape the live pubsub path emits. The live WebSocket -//! transport plumbing itself is covered by the reconnect/termination unit tests -//! in `tests/reactive_alloy_subscriber.rs`. -#![cfg(feature = "reactive-ws")] +//! `ReactiveInputBatch` shape used by every live transport. Polling is selected +//! here because Alloy's mocked provider can install filter streams but cannot +//! install WebSocket subscriptions. WebSocket reconnect/termination plumbing is +//! covered separately in `tests/reactive_alloy_subscriber.rs`. +#![cfg(feature = "reactive-polling")] mod common; @@ -20,7 +21,7 @@ use std::sync::Arc; use alloy_network::Ethereum; use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256, keccak256}; use alloy_provider::ProviderBuilder; -use alloy_rpc_types_eth::{Filter, Log}; +use alloy_rpc_types_eth::{Block, Filter, Header, Log}; use alloy_transport::mock::Asserter; use anyhow::{Result, bail}; @@ -93,6 +94,20 @@ fn swap_log(pool: Address, topic: B256, block_number: u64, value: u64) -> Log { } } +fn rpc_block(block_number: u64) -> Block { + Block::empty(Header { + hash: B256::repeat_byte(block_number as u8), + inner: alloy_consensus::Header { + number: block_number, + parent_hash: B256::repeat_byte(block_number.saturating_sub(1) as u8), + timestamp: 1_700_000_000 + block_number, + ..Default::default() + }, + total_difficulty: None, + size: None, + }) +} + #[tokio::test(flavor = "multi_thread")] async fn alloy_subscriber_batch_feeds_reactive_runtime_ingest_end_to_end() -> Result<()> { let pool = Address::repeat_byte(0xcd); @@ -106,23 +121,29 @@ async fn alloy_subscriber_batch_feeds_reactive_runtime_ingest_end_to_end() -> Re // Real AlloySubscriber over a mocked provider; the backfill get_logs returns // one swap log carrying the post-state value. let asserter = Asserter::new(); + asserter.push_success(&U256::from(1)); // eth_chainId + asserter.push_success(&U256::from(2)); // eth_newFilter before backfill + asserter.push_success(&Some(rpc_block(100))); asserter.push_success(&vec![swap_log(pool, topic, 100, new_value)]); + asserter.push_success(&Some(rpc_block(100))); let provider = ProviderBuilder::new().connect_mocked_client(asserter); let mut subscriber = AlloySubscriber::<_, Ethereum>::new( provider, - SubscriberMode::PubSub, + SubscriberMode::Polling, SubscriberConfig { hydrate_pending_transactions: false, ..SubscriberConfig::default() }, ); - subscriber.add_interest_owner_with_backfill( - HandlerId::new("pool"), - &[ReactiveInterest::Logs(LogInterest { - provider_filter: Filter::new().address(pool).event_signature(topic), - local_matcher: None, - route_key: None, - })], + subscriber.replace_interest_owners_with_global_backfill( + vec![( + HandlerId::new("pool"), + vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(pool).event_signature(topic), + local_matcher: None, + route_key: None, + })], + )], SubscriberBackfill::range(90, 100), )?; diff --git a/tests/reactive_trace_resync.rs b/tests/reactive_trace_resync.rs index e8e4599..51e03cd 100644 --- a/tests/reactive_trace_resync.rs +++ b/tests/reactive_trace_resync.rs @@ -1,4 +1,4 @@ -//! Manager-authored acceptance tests for trace-backed reactive resync execution. +//! Acceptance tests for trace-backed reactive resync execution. //! //! These tests pin the Tier-3 liveness/resync path: when handlers request sync //! for a block, the runtime should be able to satisfy matching targets from one @@ -53,7 +53,7 @@ fn included_context(block_number: u64) -> ReactiveContext { chain_id: Some(1), source: InputSource::Batch, chain_status: ChainStatus::Included { - block: block.clone(), + block, confirmations: 0, }, block: Some(block), From b160b6bf433d5f6e86d4c28460fd121a333cc516 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 4 Aug 2026 14:55:11 +0100 Subject: [PATCH 2/8] Prepare fork cache alpha.2 release --- .github/workflows/ci.yml | 6 +- CHANGELOG.md | 62 +- CONTRIBUTING.md | 8 +- Cargo.lock | 95 +- Cargo.toml | 8 +- README.md | 77 +- RELEASING.md | 16 +- docs/KNOWN_ISSUES.md | 21 +- docs/ROADMAP.md | 18 +- examples/reactive_alloy_amm_live_probe.rs | 23 +- scripts/check-security-exceptions.sh | 2 +- src/access_list.rs | 96 +- src/access_set.rs | 103 +- src/cache/mod.rs | 90 +- src/cache/overlay.rs | 76 +- src/cache/read_set.rs | 333 ++ src/cache/snapshot.rs | 110 + src/lib.rs | 17 +- src/reactive/mod.rs | 4286 +++++++++++++++++++-- tests/freshness.rs | 6 + tests/reactive_flashblocks.rs | 214 +- tests/read_set_warmup.rs | 166 + tests/snapshot_overlay.rs | 89 +- 23 files changed, 5559 insertions(+), 363 deletions(-) create mode 100644 src/cache/read_set.rs create mode 100644 tests/read_set_warmup.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfeca66..c13a3c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,15 +74,15 @@ jobs: run: cargo audit --ignore RUSTSEC-2025-0055 msrv: - name: msrv (1.88) + name: msrv (1.90) runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - name: Install Rust 1.88 (declared MSRV) + - name: Install Rust 1.90 (declared MSRV) uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: - toolchain: 1.88.0 + toolchain: 1.90.0 - name: Cache cargo artifacts uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0914063..731da32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,63 @@ surface freezes at 1.0. ## [Unreleased] +## [0.4.0-alpha.2] - 2026-07-29 + +### Added + +- Added cache-owned execution read-set discovery and exact-block hydration for + account, code, storage, and block-hash dependencies, including missing-state + provenance and provider-read instrumentation. +- Added `AlloySubscriber::establish_flashblocks_preflight` to verify a pinned + Base or OP chain and establish its chain-specific Flashblocks surface while + retaining optional provider capability evidence. +- Added snapshot-resident/missing read-set inspection and offline block-hash + retention. +- Added native `newFlashblocks` plus filtered `pendingLogs` subscriptions for + Base mainnet/testnet and a bounded pending block/log sampler for Optimism + mainnet/testnet through the standard subscriber output path. +- Added per-generation Flashblocks RPC metrics for qualification and recovery + traffic. +- Added exact transaction-receipt hydration for OP pending samples, with + per-tick and rolling per-second request budgets. +- Added complete pending EVM context propagation for available number, + timestamp, base fee, beneficiary, randomness, and gas-limit fields. +- Added generation-wide invalidation delivery so downstream consumers revoke a + published speculative snapshot before coupled Flashblock subscriptions are + re-established. + +### Changed + +- Raised the minimum supported Rust version to 1.90 and updated the locked + `ruint` dependency to the release that resolves `RUSTSEC-2026-0220`. +- Preconfirmed identity is now a non-zero, provider-generation-scoped content + commitment over the cumulative preview, with any real provider-reported + partial hash retained separately. Pending logs correlate by block number and + transaction membership rather than a pending block hash. +- Base Flashblocks endpoints no longer treat `newHeads` as canonical because a + provider may expose partial heads there. Canonical progress is certified + through `eth_getBlockByNumber("latest")`. +- Optimism pending-state sampling now pins cumulative blocks, exact canonical + parents, filtered logs, and receipts to one provider generation, retries + isolated request failures, and fails closed after a configurable consecutive + failure threshold. +- Cumulative updates within one payload retain generation-local lazy fills; + replacement, reconnect, explicit discard, and canonical advancement restore + the exact canonical cache state. + +### Fixed + +- Rejected conflicting duplicate indices, duplicate cumulative transaction + membership, and unrecoverable indexed gaps before publishing incomplete or + ambiguous speculative state. +- Reconnect now treats `newFlashblocks` and every `pendingLogs` stream as one + coupled generation, purges buffered records, increments provider provenance, + and performs one cumulative pending-state recovery after resubscription. +- `PreconfirmationMode::Preferred` now isolates initial Flashblocks rejection, + Flashblocks stream termination, and exhausted Flashblocks reconnects from + the canonical subscriptions. Retry proceeds in the background while + canonical logs and heads remain deliverable; `Required` remains fail-closed. + ## [0.4.0-alpha.1] - 2026-07-28 ### Migration checklist @@ -192,7 +249,7 @@ surface freezes at 1.0. capability and reject ephemeral subscribers before polling or state mutation; the in-crate Alloy subscriber intentionally remains an ordinary live source. - Alloy dependencies are capped below 1.7 so fresh resolution cannot silently - select a release requiring a newer compiler than the declared Rust 1.88 MSRV. + select a release requiring a newer compiler than the declared Rust 1.90 MSRV. ## [0.3.0] - 2026-07-14 @@ -1061,7 +1118,8 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). - `EvmCache` requires a multi-thread tokio runtime for any RPC-touching path. - See [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md) for current limitations. -[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.1...HEAD +[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.2...HEAD +[0.4.0-alpha.2]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.1...v0.4.0-alpha.2 [0.4.0-alpha.1]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.3.0...v0.4.0-alpha.1 [0.3.0]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.2.0...v0.2.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7f9e0d..bf72c91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,16 +40,16 @@ RUSTDOCFLAGS="-D warnings" cargo doc --no-deps This is not the complete release matrix. Changes to reactive delivery, feature-gated code, persistence, or public APIs must also pass the locked all-feature tests, doctests, clippy and rustdoc gates; the polling-only, -reactive-only, and no-reactive feature checks; the Rust 1.88 library check; +reactive-only, and no-reactive feature checks; the Rust 1.90 library check; benchmark compilation; security-scope validation; and package verification in [`RELEASING.md`](RELEASING.md). The CI workflow and release checklist must stay synchronized, including the polling-only test run. ### MSRV -The minimum supported Rust version is **1.88** (edition 2024), enforced by a -dedicated CI job (`cargo check --lib --locked` on 1.88). Do not use std APIs -newer than 1.88 in the library. Dev-only code (examples, benches, tests) is not +The minimum supported Rust version is **1.90** (edition 2024), enforced by a +dedicated CI job (`cargo check --lib --locked` on 1.90). Do not use std APIs +newer than 1.90 in the library. Dev-only code (examples, benches, tests) is not MSRV-constrained. ### Crate boundary diff --git a/Cargo.lock b/Cargo.lock index edaae0a..0b0da62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -668,7 +668,7 @@ dependencies = [ [[package]] name = "alloy-transport-balancer" -version = "0.3.0-alpha.1" +version = "0.3.0-alpha.2" dependencies = [ "alloy-json-rpc", "alloy-transport", @@ -873,6 +873,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff-asm" version = "0.3.0" @@ -903,6 +920,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "ark-ff-macros" version = "0.3.0" @@ -941,6 +968,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ark-poly" version = "0.5.0" @@ -1012,13 +1052,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ - "ark-serialize-derive", + "ark-serialize-derive 0.5.0", "ark-std 0.5.0", "arrayvec", "digest 0.10.7", "num-bigint", ] +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + [[package]] name = "ark-serialize-derive" version = "0.5.0" @@ -1030,6 +1083,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ark-std" version = "0.3.0" @@ -1060,6 +1124,16 @@ dependencies = [ "rand 0.8.7", ] +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.7", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -1982,12 +2056,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "evm-fork-cache" -version = "0.4.0-alpha.1" +version = "0.4.0-alpha.2" dependencies = [ "alloy-consensus", "alloy-contract", @@ -2731,7 +2805,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3965,14 +4039,15 @@ dependencies = [ [[package]] name = "ruint" -version = "1.17.2" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", "ark-ff 0.4.2", "ark-ff 0.5.0", + "ark-ff 0.6.0", "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", @@ -4037,7 +4112,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4605,7 +4680,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5235,7 +5310,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b51d7a1..55d39ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "evm-fork-cache" -version = "0.4.0-alpha.1" +version = "0.4.0-alpha.2" edition = "2024" -rust-version = "1.88" +rust-version = "1.90" license = "MIT OR Apache-2.0" description = "Forked EVM state cache, snapshots, overlays, and simulation utilities for EVM search" keywords = ["evm", "revm", "defi", "simulation", "ethereum"] @@ -47,7 +47,7 @@ alloy-rlp = "0.3" alloy-rpc-client = ">=1.0.38, <1.7" alloy-rpc-types-eth = ">=1.0.38, <1.7" alloy-sol-types = ">=1.4, <1.7" -alloy-transport-balancer = { version = "0.3.0-alpha.1", path = "../alloy-transport-balancer", default-features = false } +alloy-transport-balancer = { version = "0.3.0-alpha.2", path = "../alloy-transport-balancer", default-features = false } futures = "0.3" bincode = "1.3" @@ -60,7 +60,7 @@ revm = { version = "34.0", features = ["std", "serde", "optional_eip3607", "opti # installs the ring provider best-effort at WS init. Do not remove without # re-checking the TLS provider story. rustls = { version = "0.23", default-features = false, optional = true } -serde = { version = "1.0.228", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive", "rc"] } thiserror = "2.0" serde_json = "1.0.145" tokio = { version = "1.48.0", features = ["rt-multi-thread", "time"] } diff --git a/README.md b/README.md index 7374dd0..8fa156e 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,10 @@ The reactive subscriber contract became asynchronous and explicitly durable in when a complete canonical header is available. - Choose an explicit `SubscriberConfig::preconfirmations` policy. The default is `PreconfirmationMode::Disabled`; `Preferred` falls back on unsupported chains, - while `Required` fails closed when the chain, transport, or stable provider - identity cannot supply Flashblocks. + keeps canonical subscriptions live through Flashblocks rejection, + termination, and background reconnect exhaustion, while `Required` fails + closed when the chain, transport, or stable provider identity cannot supply + Flashblocks. - Attach a `ProviderRef` to Flashblocks-enabled `AlloySubscriber` sessions. The endpoint ID is propagated into every preconfirmed record so pending reads can remain pinned to the announcing provider and later canonical reads can prefer it. @@ -338,25 +340,72 @@ let subscriber = AlloySubscriber::new(provider, SubscriberMode::PubSub, config) # } ``` -- **Base** (`8453`, `84532`) consumes both native `newFlashblocks` markers and - filter-shaped `pendingLogs`. Logs are buffered until their partial-block hash - can be correlated with the cumulative Flashblock identity, regardless of - arrival order. Reconnect or index gaps recover from the endpoint's cumulative - `pending` snapshot. -- **OP** (`10`, `11155420`) samples the documented standard `pending` state at - `flashblock_poll_interval`, deduplicating cumulative logs while canonical - block subscriptions continue normally. `Required` verifies that the endpoint - actually exposes a pending block ahead of the canonical head. +- **Base** (`8453`, `84532`) consumes native `newFlashblocks` plus + filter-shaped `pendingLogs`. Both subscription lanes and every recovery read + share one provider lease and generation. +- Pending logs are buffered until the cumulative preview for the same block + contains their transaction hash. Provider-supplied zero `hash`/`blockHash` + placeholders are never used as identities; each exact cumulative view instead + receives a non-zero, provider-generation-scoped content commitment. +- Indexed gaps recover once from the endpoint's cumulative `pending` snapshot. + Conflicting duplicate indices, duplicate transaction membership, an + unrecoverable gap, or either Base subscription ending invalidates the + complete speculative generation before reconnect I/O. +- On Base Flashblocks endpoints, canonical progress is certified at + `canonical_head_poll_interval` through `eth_getBlockByNumber("latest")`. + The provider's `newHeads` feed is not trusted because Flashblocks-aware + endpoints may expose partial/preconfirmed progress through it. +- **OP** (`10`, `11155420`) uses one generation-pinned sampler for the standard + `pending` block surface, exact hash-addressed parent certification, filtered + pending logs, and bounded exact transaction receipts. The sampler runs at + `flashblock_poll_interval`, deduplicates cumulative views, rejects + non-monotonic transaction membership, and enforces + `max_flashblock_rpc_requests_per_second` across actual method calls. +- A separate state provider may be paired with the event provider through + `with_flashblocks_state_provider`; preflight verifies the paired chain before + publishing any pending data. This lets applications keep a WebSocket lease + for canonical streams while routing OP pending reads through the matching + provider's request/response endpoint. Both adapters emit `ChainStatus::Preconfirmed`, `InputSource::Flashblocks`, and `DeliveryScope::Preconfirmed`. `ReactiveRuntime` applies each cumulative Flashblock to a disposable overlay: a newer payload/provider generation replaces the previous preview, canonical input restores the saved canonical state before -commit, and `discard_preconfirmation` restores it explicitly. Preconfirmed -resyncs use the `pending` block tag. The overlay never advances canonical +commit, and `discard_preconfirmation` restores it explicitly. The cache pins +preconfirmed reads to `pending` and installs the preview's complete available +EVM block environment. Preconfirmed resyncs also use the `pending` block tag. +The overlay never advances canonical coverage, finality, health, rollback journals, or durable checkpoints; the checkpointed engine rejects speculative batches rather than persisting them. +After registering at least one active log interest, call +`AlloySubscriber::establish_flashblocks_preflight(expected_chain_id)` with a +15-second outer timeout. It verifies the pinned Base or OP chain and retains an +optional opaque `op_supportedCapabilities` response. Base acknowledges both +native subscription lanes; OP probes its bounded pending block, exact parent, +filtered log, and receipt methods. The returned filter/subscription counts make +the covered stream set explicit. Endpoint +qualification still requires a live acceptance window that observes advancing +Flashblocks and a correlated active-pool pending log; acknowledgement or a +successful probe alone is not liveness. + +### Execution read-set warming + +`StorageAccessList` covers accounts, runtime-code identities, storage slots, and +`BLOCKHASH` dependencies. Provider-backed caches can discover large unknown call +read sets with exact-block `eth_createAccessList` probes through +`EvmCache::prewarm_read_sets`; small or known sets continue through the ordinary +bulk loader. `EvmCache::hydrate_read_set` refreshes account headers and storage +together with exact-pin `eth_getProof`, reports incomplete proofs, and rejects a +runtime-code hash change instead of reusing slot identifiers across layouts. + +Snapshots expose `resident_read_set` and `missing_read_set`, retain cached block +hashes, and RPC-disconnected overlays return a precise `MissingState`. Consumers +can therefore warm a canonical baseline before attaching subscriptions, prove a +speculative simulation performed no provider reads, and carry newly discovered +dependencies into the next canonical hydration cycle without issuing RPCs on a +Flashblock hot path. + - **Cold-start** — declaratively warm a working set of accounts and storage slots into the cache in one batched pass via `EvmCache::run_cold_start` and a `ColdStartPlanner` (discover slots via a view-call, then verify them), returning @@ -895,7 +944,7 @@ protocol-specific storage layouts, and DeFi adapters belong in the companion releases** — the roadmap deliberately reshapes the API before the surface freezes. Each release documents its breaking changes in [`CHANGELOG.md`](CHANGELOG.md). -- **MSRV:** Rust 1.88 (enforced in CI). Edition 2024. +- **MSRV:** Rust 1.90 (enforced in CI). Edition 2024. - **Semver:** pre-1.0 minor versions may break; patch versions will not. - **Roadmap:** see [`docs/ROADMAP.md`](docs/ROADMAP.md) for the path to 1.0. - **Known issues / limitations:** see [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md). diff --git a/RELEASING.md b/RELEASING.md index 4c6faa7..e211a01 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,9 +1,9 @@ # Releasing -`evm-fork-cache` 0.4.0-alpha.1 is the second prerelease in the Flashblocks -compatibility set. Publish `alloy-transport-balancer 0.3.0-alpha.1` first, then +`evm-fork-cache` 0.4.0-alpha.2 is the second prerelease in the Flashblocks +compatibility set. Publish `alloy-transport-balancer 0.3.0-alpha.2` first, then publish this crate before any extension crate that declares -`evm-fork-cache = "0.4.0-alpha.1"`, including `evm-amm-state 0.3.0-alpha.1` and +`evm-fork-cache = "0.4.0-alpha.2"`, including `evm-amm-state 0.3.0-alpha.2` and the remote/Hybrid subscriber packages. No release step is automatic: use clean, reviewed commits and never publish from a credential-bearing working tree. @@ -23,7 +23,7 @@ cargo check --locked --no-default-features --features reactive-polling cargo check --locked --no-default-features --features reactive-ws cargo clippy --locked --all-targets --no-default-features --features reactive-polling --no-deps -- -D warnings cargo test --locked --no-default-features --features reactive-polling -cargo +1.88.0 check --locked --lib +cargo +1.90.0 check --locked --lib cargo bench --no-run --all-features --locked bash scripts/check-security-exceptions.sh cargo audit --ignore RUSTSEC-2025-0055 @@ -42,7 +42,7 @@ removed. Confirm every third-party `uses:` entry remains pinned to the officially verified full commit recorded in `SECURITY.md`, not a mutable tag or branch. The stable and MSRV jobs must use the same pinned `dtolnay/rust-toolchain` -action with explicit `toolchain: stable` and `toolchain: 1.88.0` inputs. +action with explicit `toolchain: stable` and `toolchain: 1.90.0` inputs. Inspect `cargo package --list --locked` and confirm that secrets, local databases, planning/spec documents, and build output are excluded while consumer @@ -61,11 +61,11 @@ the core's retained canonical history exactly. ```bash cargo publish --locked -git tag -s v0.4.0-alpha.1 -m "Release evm-fork-cache v0.4.0-alpha.1" -git push origin v0.4.0-alpha.1 +git tag -s v0.4.0-alpha.2 -m "Release evm-fork-cache v0.4.0-alpha.2" +git push origin v0.4.0-alpha.2 ``` -Wait for 0.4.0-alpha.1 to appear in the crates.io index before removing sibling path +Wait for 0.4.0-alpha.2 to appear in the crates.io index before removing sibling path dependencies and verifying downstream extension packages. Publish only after explicit authorization; preparing or running this checklist is not permission to publish, tag, or push. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 10cc8c5..66b519e 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -108,19 +108,12 @@ surface was moved out of this crate. ## Limitations by design / roadmap -- **Preconfirmed branch replacement discards lazy reads made after branch - capture.** The Flashblocks runtime takes a complete canonical cache snapshot - before applying the first preconfirmed payload and restores that snapshot - when the speculative branch is replaced or discarded. This is deliberately - fail-safe for correctness, but it also removes unrelated account/storage - values fetched lazily by simulations while the branch was active. Repeated - quotes can therefore pay the same provider round trip again on later - Flashblocks even when their read set is unchanged. Cumulative updates within - one payload keep the active branch and retain those reads. The alpha accepts - this performance limitation; production rollout is gated on canonical - read-set priming plus selective speculative rollback (or an equivalent - persistent warm layer), provider-read-count regression coverage, and a repeat - live latency benchmark. +- **Unlearned speculative reads remain generation-local.** Same-payload + cumulative previews retain them, while replacement, reconnect, canonical + advancement, and explicit invalidation discard them. This is intentional: + learned read-set identities persist separately and are hydrated only against + an exact canonical point, so pending values can never leak into canonical + state. - **Storage-only freshness verification; `ConfirmedFull` is defined but not yet emitted.** The optimistic verify-and-rerun loop builds its verify set from the volatile storage *slots* in each sim's read set, and its success verdict says @@ -321,5 +314,5 @@ surface was moved out of this crate. in `tests/reactive_engine.rs`. The live WebSocket transport plumbing is covered by reconnect/termination unit tests but not by a networked end-to-end test. These are tracked follow-ups, not known defects. -- **Recent toolchain.** MSRV 1.88 and edition 2024 are intentional and +- **Recent toolchain.** MSRV 1.90 and edition 2024 are intentional and CI-enforced; consumers on older toolchains are not supported. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 98a5e5d..c33ed1b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -608,15 +608,15 @@ acceptance contract in the spec (`tests/liveness_*`). ## Remaining work toward 1.0 -1. **Preconfirmation read-set retention (production-rollout gate).** Prime - representative simulation read sets before subscriber attachment, then - replace whole-cache preconfirmation restore with target-scoped rollback or - an equivalent persistent canonical warm layer. Speculative account, slot, - balance, resync, and purge effects must still be removed exactly, while - unrelated lazy fills survive branch replacement. Acceptance requires - provider-read-count tests across cumulative/replaced/discarded payloads and a - repeat paid-provider benchmark with no recurring RPC-scale quote-latency - mode. +1. **Preconfirmation read-set retention (closed in 0.4.0-alpha.2).** + Representative execution read sets can be discovered and hydrated at an + exact canonical pin before subscriber attachment. Their account, code, + storage, and block-hash identities persist outside disposable speculative + values; cumulative views retain generation-local fills, while replacement, + discard, reconnect, and canonical advancement remove pending effects. Tests + cover resident/missing sets, exact hydration, code-identity invalidation, + cumulative/replaced/discarded payloads, and zero provider reads on repeated + offline execution. 2. **Bundle-simulation breadth (Phase 7 — core shipped).** `EvmOverlay::simulate_bundle` now evaluates an ordered tx sequence over cumulative state with a revert policy and coinbase-payment accounting, and a `CallTracer` reconstructs the call-frame diff --git a/examples/reactive_alloy_amm_live_probe.rs b/examples/reactive_alloy_amm_live_probe.rs index b1af2fa..840d90a 100644 --- a/examples/reactive_alloy_amm_live_probe.rs +++ b/examples/reactive_alloy_amm_live_probe.rs @@ -265,11 +265,7 @@ where removed += 1; } (status, was_removed) => { - bail!( - "unexpected chain status {:?} for removed={}", - status, - was_removed - ); + bail!("unexpected chain status {status:?} for removed={was_removed}"); } } @@ -289,10 +285,7 @@ where } } - println!( - "summary: observed {} log(s), {} removed/reorged", - total, removed - ); + println!("summary: observed {total} log(s), {removed} removed/reorged"); for target in &targets { println!( " {:<34} {}", @@ -303,9 +296,7 @@ where if total < min_events { bail!( - "observed {} log(s), below LIVE_AMM_MIN_EVENTS={}; increase LIVE_AMM_SECONDS or use a filter-capable RPC endpoint", - total, - min_events + "observed {total} log(s), below LIVE_AMM_MIN_EVENTS={min_events}; increase LIVE_AMM_SECONDS or use a filter-capable RPC endpoint" ); } @@ -325,8 +316,7 @@ where let mut total = 0usize; println!( - "preflight: scanning recent AMM logs over blocks {}..={} with the same filters", - from, latest + "preflight: scanning recent AMM logs over blocks {from}..={latest} with the same filters" ); for target in targets { let logs = provider @@ -338,12 +328,11 @@ where if total == 0 { bail!( - "preflight observed zero AMM logs over the last {} block(s); filters or endpoint are not suitable for this probe", - blocks + "preflight observed zero AMM logs over the last {blocks} block(s); filters or endpoint are not suitable for this probe" ); } - println!("preflight: observed {} recent log(s)", total); + println!("preflight: observed {total} recent log(s)"); Ok(()) } diff --git a/scripts/check-security-exceptions.sh b/scripts/check-security-exceptions.sh index b8b81d2..59079ad 100755 --- a/scripts/check-security-exceptions.sh +++ b/scripts/check-security-exceptions.sh @@ -159,7 +159,7 @@ bincode_graph="$({ } | sed -E 's# \(/[^)]*\)$##; s# \(\*\)$##')" expected_bincode_graph="$(printf '%s\n' \ '0bincode v1.3.3' \ - '1evm-fork-cache v0.4.0-alpha.1')" + '1evm-fork-cache v0.4.0-alpha.2')" if [[ "$bincode_graph" != "$expected_bincode_graph" ]]; then echo "The accepted bincode 1 compatibility scope changed." >&2 echo "Expected:" >&2 diff --git a/src/access_list.rs b/src/access_list.rs index babcdad..1f88ccd 100644 --- a/src/access_list.rs +++ b/src/access_list.rs @@ -20,10 +20,10 @@ //! so use `into_access_list_always()` to skip the profitability check. use alloy_eips::{ - BlockNumberOrTag, + BlockId, BlockNumberOrTag, eip2930::{AccessList, AccessListItem}, }; -use alloy_network::Network; +use alloy_network::{AnyNetwork, Network}; use alloy_primitives::{Address, B256, Bytes, U256, address}; use alloy_provider::Provider; use alloy_rlp::Encodable; @@ -32,6 +32,7 @@ use alloy_sol_types::{SolCall, sol}; use revm::context::result::ExecutionResult; use tracing::{debug, info}; +use crate::access_set::StorageAccessList; use crate::cache::EvmCache; use crate::errors::{AccessListError, AccessListResult as Result}; @@ -45,6 +46,26 @@ const ARB_GAS_INFO: Address = address!("000000000000000000000000000000000000006C /// ([`compute_op_l1_fee`]). pub const OP_GAS_PRICE_ORACLE: Address = address!("420000000000000000000000000000000000000F"); +/// Default gas cap for an `eth_createAccessList` read-set probe. +pub const DEFAULT_CREATE_ACCESS_LIST_GAS_CAP: u64 = 30_000_000; + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateAccessListProbe { + #[serde(default)] + access_list: Vec, + #[serde(default)] + error: Option, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateAccessListProbeItem { + address: Address, + #[serde(default)] + storage_keys: Option>, +} + /// Chain fee model used by helpers that only need to identify the chain's L1 /// base-fee oracle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -73,6 +94,77 @@ pub enum AccessListPricing { }, } +/// Ask a provider to derive the account/storage touch set for `request` at an +/// exact block. +pub async fn create_access_list_read_set

( + provider: &P, + block: BlockId, + request: TransactionRequest, +) -> Result +where + P: Provider, +{ + let gas_price = default_access_list_gas_price(provider, block).await; + create_access_list_read_set_with_gas_price(provider, block, request, gas_price).await +} + +pub(crate) async fn create_access_list_read_set_with_gas_price

( + provider: &P, + block: BlockId, + mut request: TransactionRequest, + default_gas_price: u128, +) -> Result +where + P: Provider, +{ + if request.gas.is_none() { + request.gas = Some(DEFAULT_CREATE_ACCESS_LIST_GAS_CAP); + } + if request.gas_price.is_none() + && request.max_fee_per_gas.is_none() + && request.max_priority_fee_per_gas.is_none() + { + request.gas_price = Some(default_gas_price); + } + let result: CreateAccessListProbe = provider + .client() + .request("eth_createAccessList", (request, block)) + .await + .map_err(|error| AccessListError::query("eth_createAccessList", error))?; + if let Some(error) = result.error { + return Err(AccessListError::query( + "eth_createAccessList execution", + error, + )); + } + + let mut access = StorageAccessList::default(); + for item in result.access_list { + access.accounts.insert(item.address); + if let Some(storage_keys) = item.storage_keys { + access.slots.extend( + storage_keys + .into_iter() + .map(|key| (item.address, U256::from_be_slice(key.as_slice()))), + ); + } + } + Ok(access) +} + +pub(crate) async fn default_access_list_gas_price

(provider: &P, block: BlockId) -> u128 +where + P: Provider, +{ + let base_fee = provider + .get_block(block) + .await + .ok() + .flatten() + .and_then(|block| block.header.base_fee_per_gas.map(u128::from)); + base_fee.unwrap_or(1_000_000_000) +} + sol! { #[sol(rpc)] interface ArbGasInfo { diff --git a/src/access_set.rs b/src/access_set.rs index 3e511f0..73fcd2d 100644 --- a/src/access_set.rs +++ b/src/access_set.rs @@ -19,14 +19,23 @@ use serde::{Deserialize, Serialize}; pub struct StorageAccessList { /// Contract addresses touched during execution. pub accounts: HashSet

, + /// Runtime-code hashes requested during execution. + #[serde(default)] + pub code_hashes: HashSet, /// `(contract, slot)` pairs read or written during execution. pub slots: HashSet<(Address, U256)>, + /// Historical block numbers requested through the `BLOCKHASH` opcode. + #[serde(default)] + pub block_numbers: HashSet, } impl StorageAccessList { /// Returns true when no accounts or storage slots were captured. pub fn is_empty(&self) -> bool { - self.accounts.is_empty() && self.slots.is_empty() + self.accounts.is_empty() + && self.code_hashes.is_empty() + && self.slots.is_empty() + && self.block_numbers.is_empty() } /// Number of distinct accounts touched by the execution. @@ -39,6 +48,16 @@ impl StorageAccessList { self.slots.len() } + /// Number of distinct runtime-code hashes touched by the execution. + pub fn code_hash_count(&self) -> usize { + self.code_hashes.len() + } + + /// Number of distinct historical block hashes touched by the execution. + pub fn block_hash_count(&self) -> usize { + self.block_numbers.len() + } + /// Merge another touch set into this one (set union of accounts and slots). /// /// Duplicate accounts and `(account, slot)` pairs already present are not @@ -71,7 +90,40 @@ impl StorageAccessList { /// ``` pub fn extend(&mut self, other: &Self) { self.accounts.extend(&other.accounts); + self.code_hashes.extend(&other.code_hashes); self.slots.extend(&other.slots); + self.block_numbers.extend(&other.block_numbers); + } + + /// Return the subset of this required read set absent from `available`. + pub fn missing_from(&self, available: &Self) -> Self { + Self { + accounts: self + .accounts + .difference(&available.accounts) + .copied() + .collect(), + code_hashes: self + .code_hashes + .difference(&available.code_hashes) + .copied() + .collect(), + slots: self.slots.difference(&available.slots).copied().collect(), + block_numbers: self + .block_numbers + .difference(&available.block_numbers) + .copied() + .collect(), + } + } + + /// Whether every account and slot in this read set is present in + /// `available`. + pub fn is_covered_by(&self, available: &Self) -> bool { + self.accounts.is_subset(&available.accounts) + && self.code_hashes.is_subset(&available.code_hashes) + && self.slots.is_subset(&available.slots) + && self.block_numbers.is_subset(&available.block_numbers) } /// Compute EIP-2929 gas saved when this touch set runs after `warm`. @@ -139,10 +191,12 @@ mod tests { slots: [(account_a, slot_1), (account_b, slot_2)] .into_iter() .collect(), + ..Default::default() }; let warm = StorageAccessList { accounts: [account_a].into_iter().collect(), slots: [(account_b, slot_2)].into_iter().collect(), + ..Default::default() }; assert_eq!(al.marginal_gas_savings(&warm), 4500); @@ -168,4 +222,51 @@ mod tests { assert!(encoded.0.iter().any(|item| item.address == storage_contract && item.storage_keys == vec![B256::from(U256::from(4))])); } + + #[test] + fn missing_from_returns_only_unwarmed_accounts_and_slots() { + let warm_account = Address::repeat_byte(0x01); + let cold_account = Address::repeat_byte(0x02); + let warm_slot = (warm_account, U256::from(1)); + let cold_slot = (cold_account, U256::from(2)); + let required = StorageAccessList { + accounts: [warm_account, cold_account].into_iter().collect(), + slots: [warm_slot, cold_slot].into_iter().collect(), + ..Default::default() + }; + let available = StorageAccessList { + accounts: [warm_account].into_iter().collect(), + slots: [warm_slot].into_iter().collect(), + ..Default::default() + }; + + let missing = required.missing_from(&available); + + assert_eq!(missing.accounts, [cold_account].into_iter().collect()); + assert_eq!(missing.slots, [cold_slot].into_iter().collect()); + assert!(!required.is_covered_by(&available)); + assert!(available.is_covered_by(&required)); + } + + #[test] + fn missing_from_includes_code_and_block_hash_dependencies() { + let warm_code = B256::repeat_byte(0x11); + let cold_code = B256::repeat_byte(0x22); + let required = StorageAccessList { + code_hashes: [warm_code, cold_code].into_iter().collect(), + block_numbers: [90, 91].into_iter().collect(), + ..Default::default() + }; + let available = StorageAccessList { + code_hashes: [warm_code].into_iter().collect(), + block_numbers: [90].into_iter().collect(), + ..Default::default() + }; + + let missing = required.missing_from(&available); + + assert_eq!(missing.code_hashes, [cold_code].into_iter().collect()); + assert_eq!(missing.block_numbers, [91].into_iter().collect()); + assert!(!required.is_covered_by(&available)); + } } diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 451b2aa..c0d2bf8 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -20,6 +20,7 @@ mod durable_checkpoint; mod journal_access_list; mod metadata; pub mod overlay; +mod read_set; pub mod slot_observations; pub mod snapshot; pub(crate) mod versioned; @@ -33,7 +34,11 @@ pub use durable_checkpoint::{ LoadedDurableCheckpoint, }; pub use metadata::{CacheConfig, ImmutableDataCache}; -pub use overlay::EvmOverlay; +pub use overlay::{EvmOverlay, MissingState}; +pub use read_set::{ + AccessListFetchFn, ReadSetHydrationReport, ReadSetWarmupBatch, ReadSetWarmupCall, + ReadSetWarmupConfig, ReadSetWarmupReport, ReadSetWarmupStrategy, +}; pub use slot_observations::SlotObservationTracker; pub use snapshot::EvmSnapshot; @@ -1530,6 +1535,8 @@ pub struct EvmCache { /// (`inject_storage_batch`) does not bump it. snapshot_generation: u64, storage_batch_fetcher: Option, + /// Optional provider-backed `eth_createAccessList` read-set discovery. + access_list_fetcher: Option, /// Optional account/root fetcher that bypasses SharedBackend. /// Captures a provider clone and fires `eth_getProof` calls directly to fetch /// authoritative account fields (balance/nonce/code hash) and `storageHash`. @@ -2014,6 +2021,57 @@ impl EvmCache { StorageFetchStrategy::default(), ); + // Cache-owned read-set discovery. Calls are issued concurrently and + // returned in request order so a batching transport may coalesce them. + let provider_for_access_lists = provider.clone(); + let access_list_fetcher: AccessListFetchFn = Arc::new( + move |requests: Vec, block: BlockId| { + let handle = match block_in_place_handle() { + Ok(handle) => handle, + Err(error) => { + let message = error.to_string(); + return requests + .into_iter() + .map(|_| { + Err(crate::errors::AccessListError::query("runtime", &message)) + }) + .collect(); + } + }; + tokio::task::block_in_place(|| { + handle.block_on(async { + use futures::StreamExt; + + let gas_price = crate::access_list::default_access_list_gas_price( + provider_for_access_lists.as_ref(), + block, + ) + .await; + let mut results: Vec<_> = futures::stream::iter( + requests.into_iter().enumerate().map(|(index, request)| { + let provider = Arc::clone(&provider_for_access_lists); + async move { + let result = crate::access_list::create_access_list_read_set_with_gas_price( + provider.as_ref(), + block, + request, + gas_price, + ) + .await; + (index, result) + } + }), + ) + .buffer_unordered(16) + .collect() + .await; + results.sort_by_key(|(index, _)| *index); + results.into_iter().map(|(_, result)| result).collect() + }) + }) + }, + ); + // Create an account/root fetcher that bypasses SharedBackend, firing // `eth_getProof` calls directly for authoritative account fields plus the // account's `storageHash`. `eth_getProof` is single-address at the RPC @@ -2125,6 +2183,7 @@ impl EvmCache { snapshot_generation: 0, rpc_caller: Some(rpc_caller), storage_batch_fetcher: Some(storage_batch_fetcher), + access_list_fetcher: Some(access_list_fetcher), account_proof_fetcher: Some(account_proof_fetcher), block_state_diff_fetcher: Some(block_state_diff_fetcher), account_fields_fetcher: Some(account_fields_fetcher), @@ -2216,6 +2275,7 @@ impl EvmCache { ))), rpc_caller: None, storage_batch_fetcher: None, + access_list_fetcher: None, account_proof_fetcher: None, block_state_diff_fetcher: None, account_fields_fetcher: None, @@ -3613,6 +3673,7 @@ impl EvmCache { } } + let block_hashes = self.snapshot_block_hashes(); Arc::new(snapshot::EvmSnapshot { base, overlay_accounts, @@ -3620,7 +3681,7 @@ impl EvmCache { overlay_code_by_hash, storage_cleared, accounts_not_existing, - block_hashes: HashMap::new(), + block_hashes, block_number: self.block_number, basefee: self.basefee, coinbase: self.coinbase, @@ -3911,6 +3972,7 @@ impl EvmCache { code_by_hash, }; + let block_hashes = self.snapshot_block_hashes(); Arc::new(snapshot::EvmSnapshot { base: Arc::new(base), overlay_accounts: HashMap::new(), @@ -3918,7 +3980,7 @@ impl EvmCache { overlay_code_by_hash: HashMap::new(), storage_cleared, accounts_not_existing, - block_hashes: HashMap::new(), + block_hashes, block_number: self.block_number, basefee: self.basefee, coinbase: self.coinbase, @@ -3931,6 +3993,24 @@ impl EvmCache { }) } + fn snapshot_block_hashes(&self) -> HashMap { + let mut block_hashes = HashMap::new(); + { + let backend = self.blockchain_db.block_hashes().read(); + for (number, hash) in backend.iter() { + if number.bit_len() <= 64 { + block_hashes.insert(number.to::(), *hash); + } + } + } + for (number, hash) in &self.db.cache.block_hashes { + if number.bit_len() <= 64 { + block_hashes.insert(number.to::(), *hash); + } + } + block_hashes + } + /// Mark a layer-2 address dirty so the next [`refresh_base`](Self::refresh_base) /// re-folds it into the memoized base (Pillar A invalidation; see /// `docs/phase-5-spec.md` §3). @@ -5086,6 +5166,10 @@ impl EvmCache { for (address, account) in evm.journaled_state.state.iter() { if account.is_touched() { access_list.accounts.insert(*address); + let code_hash = account.info.code_hash; + if code_hash != B256::ZERO && code_hash != revm::primitives::KECCAK_EMPTY { + access_list.code_hashes.insert(code_hash); + } for slot_key in account.storage.keys() { access_list.slots.insert((*address, *slot_key)); } diff --git a/src/cache/overlay.rs b/src/cache/overlay.rs index 0139bad..f860f8d 100644 --- a/src/cache/overlay.rs +++ b/src/cache/overlay.rs @@ -9,7 +9,7 @@ //! stay local to the overlay. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::sync::Arc; @@ -44,6 +44,46 @@ type InspectorOverlayEvm<'a, INSP> = revm::MainnetEvm< INSP, >; +/// State an RPC-disconnected overlay could not resolve from its immutable +/// snapshot. +/// +/// The EVM database interface requires a value even when an offline snapshot is +/// incomplete. [`EvmOverlay`] continues to return the protocol-neutral fallback +/// (`None`, empty bytecode, ZERO storage, or ZERO block hash), but records every +/// such fallback here. Callers must treat a non-empty report as an incomplete +/// simulation rather than authoritative execution. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MissingState { + /// Account headers absent from the snapshot. + pub accounts: HashSet
, + /// Runtime-code hashes absent from the snapshot. + pub code_hashes: HashSet, + /// Storage slots absent from the snapshot. + pub storage: HashSet<(Address, U256)>, + /// In-range block numbers whose hashes were absent from the snapshot. + pub block_hashes: HashSet, +} + +impl MissingState { + /// Whether the offline overlay resolved every database read locally. + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + && self.code_hashes.is_empty() + && self.storage.is_empty() + && self.block_hashes.is_empty() + } + + /// Convert unresolved reads into the generic execution read-set shape. + pub fn as_read_set(&self) -> StorageAccessList { + StorageAccessList { + accounts: self.accounts.clone(), + code_hashes: self.code_hashes.clone(), + slots: self.storage.clone(), + block_numbers: self.block_hashes.clone(), + } + } +} + /// Per-simulation mutable overlay on an immutable snapshot. /// /// Lookup order: dirty layer → snapshot → ext_db (optional RPC fallback). @@ -85,6 +125,8 @@ pub struct EvmOverlay { /// confirming a sim whose control flow may rest on a hash its overlays /// cannot resolve. Cleared by [`Self::reset`]. blockhash_zero_fallback: bool, + /// Exact unresolved reads observed while no external database was attached. + missing_state: MissingState, } impl EvmOverlay { @@ -103,6 +145,7 @@ impl EvmOverlay { reusable_buffer: Vec::with_capacity(buffer_capacity), buffer_capacity, blockhash_zero_fallback: false, + missing_state: MissingState::default(), } } @@ -120,6 +163,7 @@ impl EvmOverlay { self.dirty_accounts.clear(); self.dirty_storage.clear(); self.blockhash_zero_fallback = false; + self.missing_state = MissingState::default(); // Keep: snapshot Arc, ext_db, and the reusable buffer. The buffer is // already cleared after each call, so nothing to do for it here. } @@ -141,6 +185,16 @@ impl EvmOverlay { self.blockhash_zero_fallback } + /// Exact database reads that fell back because this overlay has no external + /// provider and its immutable snapshot did not contain the requested state. + /// + /// A non-empty report makes the simulation non-authoritative even when EVM + /// execution itself returned success: a missing storage slot, for example, + /// is represented as ZERO to satisfy the database trait. + pub fn missing_state(&self) -> &MissingState { + &self.missing_state + } + /// Chain ID of the block context captured by the underlying snapshot. /// /// This is the value installed into `cfg.chain_id` by [`Self::build_evm`]. @@ -1033,6 +1087,12 @@ impl EvmOverlay { for (address, account) in evm.journaled_state.state.iter() { if account.is_touched() { access_list.accounts.insert(*address); + let code_hash = account.info.code_hash; + if code_hash != B256::ZERO + && code_hash != revm::primitives::KECCAK_EMPTY + { + access_list.code_hashes.insert(code_hash); + } for slot_key in account.storage.keys() { access_list.slots.insert((*address, *slot_key)); } @@ -1344,6 +1404,7 @@ impl Database for EvmOverlay { } return Ok(info); } + self.missing_state.accounts.insert(address); Ok(None) } @@ -1364,6 +1425,7 @@ impl Database for EvmOverlay { if let Some(ref ext_db) = self.ext_db { return ext_db.code_by_hash_ref(code_hash); } + self.missing_state.code_hashes.insert(code_hash); Ok(Bytecode::default()) } @@ -1390,6 +1452,7 @@ impl Database for EvmOverlay { .insert(index, value); return Ok(value); } + self.missing_state.storage.insert((address, index)); Ok(U256::ZERO) } @@ -1400,13 +1463,12 @@ impl Database for EvmOverlay { if let Some(ref ext_db) = self.ext_db { return ext_db.block_hash_ref(number); } - // Snapshots never populate `block_hashes` (the live cache does not track - // block hashes), so without an `ext_db` the `BLOCKHASH` opcode resolves to - // ZERO. Overlays built internally (e.g. the freshness validator) pass - // `ext_db = None`; the fallback is recorded so the validator can fail - // closed (`Unverified`) instead of confirming a sim whose control flow - // may depend on the real hash. See `blockhash_zero_fallback()`. + // A hash that was not resident when the snapshot was taken cannot be + // fetched by an RPC-disconnected overlay, so `BLOCKHASH` resolves to + // ZERO. The fallback is recorded so readiness validation fails closed + // instead of confirming control flow that may depend on the real hash. self.blockhash_zero_fallback = true; + self.missing_state.block_hashes.insert(number); Ok(B256::ZERO) } } diff --git a/src/cache/read_set.rs b/src/cache/read_set.rs new file mode 100644 index 0000000..d128e46 --- /dev/null +++ b/src/cache/read_set.rs @@ -0,0 +1,333 @@ +//! Cache-owned execution read-set discovery and bulk warming. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; + +use alloy_eips::BlockId; +use alloy_primitives::{Address, U256}; +use alloy_rpc_types_eth::TransactionRequest; + +use super::{EvmCache, PrewarmReport}; +use crate::access_set::StorageAccessList; +use crate::errors::AccessListError; + +/// Exact-block hydration result for one learned execution read set. +#[derive(Clone, Debug)] +pub struct ReadSetHydrationReport { + /// Block identity passed to every provider read. + pub block: BlockId, + /// Account headers refreshed from proofs. + pub accounts_refreshed: usize, + /// Storage slots refreshed from proofs. + pub slots_refreshed: usize, + /// Per-account provider or proof-shape failures. + pub account_failures: Vec<(Address, String)>, + /// Runtime-code identity changes that invalidate the learned layout. + pub code_changes: Vec<(Address, alloy_primitives::B256, alloy_primitives::B256)>, + /// Required reads still unavailable after hydration. + pub missing_after: StorageAccessList, +} + +impl ReadSetHydrationReport { + /// Whether every requested dependency is resident and every code identity + /// still matches the learned layout. + pub fn is_complete(&self) -> bool { + self.account_failures.is_empty() + && self.code_changes.is_empty() + && self.missing_after.is_empty() + } +} + +/// Callback for deriving calls' read sets via `eth_createAccessList`. +pub type AccessListFetchFn = Arc< + dyn Fn( + Vec, + BlockId, + ) -> Vec> + + Send + + Sync, +>; + +/// One call whose storage read set may be remotely discovered. +#[derive(Clone, Debug, Default)] +pub struct ReadSetWarmupCall { + /// RPC transaction passed to `eth_createAccessList`. + pub tx: TransactionRequest, + /// Approximate expected slot count used by the automatic strategy. + pub expected_slots: Option, + /// Optional account filter applied before hydration. + pub restrict_to: Option>, +} + +/// Declared known slots plus calls with unknown read sets. +#[derive(Clone, Debug, Default)] +pub struct ReadSetWarmupBatch { + /// Slots to load directly. + pub known_slots: Vec<(Address, U256)>, + /// Calls eligible for remote read-set discovery. + pub calls: Vec, +} + +/// Read-set discovery policy. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ReadSetWarmupStrategy { + /// Use access-list discovery only when the call hints justify its round trip. + #[default] + Auto, + /// Keep discovery local to the later simulation path. + LocalOnly, + /// Attempt access-list discovery for every declared call. + AccessList, +} + +/// Heuristic configuration for [`EvmCache::prewarm_read_sets`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReadSetWarmupConfig { + /// Discovery policy. + pub strategy: ReadSetWarmupStrategy, + /// Total hinted slots that activates access-list discovery in automatic mode. + pub min_expected_slots_for_access_list: usize, + /// Number of unhinted calls that activates discovery in automatic mode. + pub min_unhinted_calls_for_access_list: usize, +} + +impl Default for ReadSetWarmupConfig { + fn default() -> Self { + Self { + strategy: ReadSetWarmupStrategy::Auto, + min_expected_slots_for_access_list: 32, + min_unhinted_calls_for_access_list: 8, + } + } +} + +impl ReadSetWarmupConfig { + fn should_use_access_lists(&self, calls: &[ReadSetWarmupCall]) -> bool { + match self.strategy { + ReadSetWarmupStrategy::LocalOnly => false, + ReadSetWarmupStrategy::AccessList => !calls.is_empty(), + ReadSetWarmupStrategy::Auto => { + let expected: usize = calls.iter().filter_map(|call| call.expected_slots).sum(); + let unhinted = calls + .iter() + .filter(|call| call.expected_slots.is_none()) + .count(); + expected >= self.min_expected_slots_for_access_list + || unhinted >= self.min_unhinted_calls_for_access_list + } + } + } +} + +/// Outcome of cache-owned read-set warming. +#[derive(Debug, Default)] +pub struct ReadSetWarmupReport { + /// Direct known-slot hydration. + pub known: PrewarmReport, + /// Whether remote access-list discovery was attempted. + pub used_access_lists: bool, + /// Calls skipped by policy or an unavailable fetcher. + pub skipped_calls: usize, + /// Successful access-list probes. + pub access_list_successes: usize, + /// Failed probes keyed by call index. + pub access_list_failures: Vec<(usize, AccessListError)>, + /// Union of successful filtered read sets. + pub discovered_access: StorageAccessList, + /// Hydration result for discovered slots. + pub discovered: PrewarmReport, +} + +impl EvmCache { + /// Installed access-list discovery callback, if any. + pub fn access_list_fetcher(&self) -> Option<&AccessListFetchFn> { + self.access_list_fetcher.as_ref() + } + + /// Replace the access-list discovery callback. + pub fn set_access_list_fetcher(&mut self, fetcher: AccessListFetchFn) { + self.access_list_fetcher = Some(fetcher); + } + + /// Warm known slots and, when selected by policy, discover and bulk-load + /// unknown call read sets through cache-owned provider plumbing. + pub fn prewarm_read_sets( + &mut self, + batch: ReadSetWarmupBatch, + config: ReadSetWarmupConfig, + ) -> ReadSetWarmupReport { + let known = if batch.known_slots.is_empty() { + PrewarmReport::default() + } else { + self.prewarm_slots(&batch.known_slots) + }; + let mut report = ReadSetWarmupReport { + known, + ..Default::default() + }; + if batch.calls.is_empty() { + return report; + } + if !config.should_use_access_lists(&batch.calls) { + report.skipped_calls = batch.calls.len(); + return report; + } + let Some(fetcher) = self.access_list_fetcher.clone() else { + report.skipped_calls = batch.calls.len(); + return report; + }; + + report.used_access_lists = true; + let requests = batch.calls.iter().map(|call| call.tx.clone()).collect(); + let mut results = fetcher(requests, self.block).into_iter(); + let mut discovered = StorageAccessList::default(); + for (index, call) in batch.calls.iter().enumerate() { + let result = results.next().unwrap_or_else(|| { + Err(AccessListError::query( + "eth_createAccessList", + "access-list fetcher omitted a result", + )) + }); + match result { + Ok(mut access) => { + if let Some(restrict_to) = &call.restrict_to { + let keep: HashSet<_> = restrict_to.iter().copied().collect(); + access.accounts.retain(|address| keep.contains(address)); + access.slots.retain(|(address, _)| keep.contains(address)); + } + discovered.extend(&access); + report.access_list_successes += 1; + } + Err(error) => report.access_list_failures.push((index, error)), + } + } + + let mut slots: Vec<_> = discovered.slots.iter().copied().collect(); + slots.sort_unstable(); + report.discovered_access = discovered; + if !slots.is_empty() { + report.discovered = self.prewarm_slots(&slots); + } + report + } + + /// Refresh a learned execution read set at this cache's exact block pin. + /// + /// Account headers and requested storage values are fetched together with + /// `eth_getProof`, preventing values from different provider observations + /// from being combined. Existing runtime bytecode is retained only when the + /// proof reports the same code hash; a changed hash is surfaced explicitly + /// so an AMM manifest can be invalidated instead of simulating against a new + /// layout with stale slot identifiers. + pub fn hydrate_read_set(&mut self, required: &StorageAccessList) -> ReadSetHydrationReport { + let block = self.block; + let mut requests: BTreeMap> = BTreeMap::new(); + for address in &required.accounts { + requests.entry(*address).or_default(); + } + for (address, slot) in &required.slots { + requests.entry(*address).or_default().push(*slot); + } + for slots in requests.values_mut() { + slots.sort_unstable(); + slots.dedup(); + } + + let mut report = ReadSetHydrationReport { + block, + accounts_refreshed: 0, + slots_refreshed: 0, + account_failures: Vec::new(), + code_changes: Vec::new(), + missing_after: required.clone(), + }; + if requests.is_empty() { + report.missing_after = self.snapshot().missing_read_set(required); + return report; + } + let Some(fetcher) = self.account_proof_fetcher.clone() else { + report.account_failures.extend( + requests + .keys() + .copied() + .map(|address| (address, "no account proof fetcher installed".to_owned())), + ); + return report; + }; + + let requested: Vec<_> = requests + .iter() + .map(|(address, slots)| (*address, slots.clone())) + .collect(); + let fetched: HashMap<_, _> = fetcher(requested, block).into_iter().collect(); + let mut fresh_slots = Vec::new(); + + for (address, expected_slots) in requests { + let Some(result) = fetched.get(&address) else { + report.account_failures.push(( + address, + "account proof fetcher omitted the requested address".to_owned(), + )); + continue; + }; + let proof = match result { + Ok(proof) => proof, + Err(error) => { + report.account_failures.push((address, error.to_string())); + continue; + } + }; + + let current = self.local_account_info(address); + if let Some(current) = current.as_ref() + && current.code_hash != proof.code_hash + { + report + .code_changes + .push((address, current.code_hash, proof.code_hash)); + continue; + } + if current.is_none() + && proof.code_hash != alloy_primitives::B256::ZERO + && proof.code_hash != revm::primitives::KECCAK_EMPTY + { + report.account_failures.push(( + address, + "runtime code was not resident for a deployed account".to_owned(), + )); + continue; + } + + let mut info = current.unwrap_or_default(); + info.balance = proof.balance; + info.nonce = proof.nonce; + info.code_hash = proof.code_hash; + self.write_account_info_through(address, info); + report.accounts_refreshed += 1; + + let by_slot: HashMap<_, _> = proof.slots.iter().copied().collect(); + let mut complete_slots = true; + for slot in expected_slots { + let Some(value) = by_slot.get(&slot).copied() else { + report.account_failures.push(( + address, + format!("account proof omitted requested storage slot {slot}"), + )); + complete_slots = false; + continue; + }; + fresh_slots.push((address, slot, value)); + } + if !complete_slots { + continue; + } + } + + report.slots_refreshed = fresh_slots.len(); + if !fresh_slots.is_empty() { + self.inject_storage_batch_fresh(&fresh_slots); + } + report.missing_after = self.snapshot().missing_read_set(required); + report + } +} diff --git a/src/cache/snapshot.rs b/src/cache/snapshot.rs index b53b08b..8048d2a 100644 --- a/src/cache/snapshot.rs +++ b/src/cache/snapshot.rs @@ -44,6 +44,8 @@ use alloy_primitives::{Address, B256, U256}; use revm::primitives::hardfork::SpecId; use revm::state::{AccountInfo, Bytecode}; +use crate::access_set::StorageAccessList; + /// Memoized, immutable flatten of the **cold layer-2** index (Pillar A). /// /// Holds layer-2 (`BlockchainDb`) account info and storage only; the layer-1 @@ -121,6 +123,114 @@ pub struct EvmSnapshot { } impl EvmSnapshot { + /// Chain ID captured by this immutable simulation snapshot. + pub const fn chain_id(&self) -> u64 { + self.chain_id + } + + /// Block number installed in the snapshot's EVM context. + pub const fn block_number(&self) -> Option { + self.block_number + } + + /// Base fee installed in the snapshot's EVM context. + pub const fn basefee(&self) -> Option { + self.basefee + } + + /// Block beneficiary installed in the snapshot's EVM context. + pub const fn coinbase(&self) -> Option
{ + self.coinbase + } + + /// PREVRANDAO value installed in the snapshot's EVM context. + pub const fn prevrandao(&self) -> Option { + self.prevrandao + } + + /// Block gas limit installed in the snapshot's EVM context. + pub const fn gas_limit(&self) -> Option { + self.gas_limit + } + + /// Timestamp installed in the snapshot's EVM context. + pub const fn timestamp(&self) -> Option { + self.timestamp + } + + /// Enumerate account, code, explicit storage, and block-hash entries held by + /// this immutable snapshot. + /// + /// Accounts already proven absent are included because their `None` result + /// is locally authoritative. Storage entries implied to be zero by a + /// `StorageCleared` account are not enumerable; use + /// [`missing_read_set`](Self::missing_read_set) when checking a concrete + /// required set. + pub fn resident_read_set(&self) -> StorageAccessList { + let mut resident = StorageAccessList::default(); + resident.accounts.extend(self.base.accounts.keys().copied()); + resident + .accounts + .extend(self.overlay_accounts.keys().copied()); + resident + .accounts + .extend(self.accounts_not_existing.iter().copied()); + resident + .code_hashes + .extend(self.base.code_by_hash.keys().copied()); + resident + .code_hashes + .extend(self.overlay_code_by_hash.keys().copied()); + for (address, slots) in &self.base.storage { + resident + .slots + .extend(slots.keys().copied().map(|slot| (*address, slot))); + } + for (address, slots) in &self.overlay_storage { + resident + .slots + .extend(slots.keys().copied().map(|slot| (*address, slot))); + } + resident + .block_numbers + .extend(self.block_hashes.keys().copied()); + resident + } + + /// Return the concrete subset of `required` this snapshot cannot resolve + /// without an external database. + pub fn missing_read_set(&self, required: &StorageAccessList) -> StorageAccessList { + StorageAccessList { + accounts: required + .accounts + .iter() + .copied() + .filter(|address| { + !self.accounts_not_existing.contains(address) + && self.account_info(*address).is_none() + }) + .collect(), + code_hashes: required + .code_hashes + .iter() + .copied() + .filter(|hash| self.code(*hash).is_none()) + .collect(), + slots: required + .slots + .iter() + .copied() + .filter(|(address, slot)| self.storage_value(*address, *slot).is_none()) + .collect(), + block_numbers: required + .block_numbers + .iter() + .copied() + .filter(|number| !self.block_hashes.contains_key(number)) + .collect(), + } + } + /// Account info as the EVM sees it: overlay (layer 1) wins, else the base /// (layer 2), else `None`. /// diff --git a/src/lib.rs b/src/lib.rs index 407fdcb..5666841 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -131,7 +131,7 @@ //! //! This crate is **pre-1.0** and developed against a phased roadmap (see //! `docs/ROADMAP.md`). Until 1.0, breaking changes may land in minor releases; -//! each is recorded in the crate `CHANGELOG.md`. MSRV is Rust 1.88 (edition 2024). +//! each is recorded in the crate `CHANGELOG.md`. MSRV is Rust 1.90 (edition 2024). //! //! The `examples/` directory has runnable, documented walkthroughs of each //! module — offline ones that need no network, plus a few that fork real chain @@ -159,6 +159,7 @@ pub mod reactive; pub mod state_update; pub mod tracing; +pub use access_list::{DEFAULT_CREATE_ACCESS_LIST_GAS_CAP, create_access_list_read_set}; pub use access_set::StorageAccessList; // Bulk storage extraction over eth_call state overrides — the default batch // storage fetcher since 0.2.0 (see docs/bulk-storage-extraction.md). @@ -174,12 +175,14 @@ pub use bundle::{BundleOptions, BundleResult, BundleTx, RevertPolicy, TxOutcome} // fully-qualified module paths (`cache::EvmCache`, `reactive::ReactiveRuntime`, // …) remain valid, so this is purely additive. pub use cache::{ - AccountFieldsFetchFn, AccountProof, AccountProofFetchFn, BlockContextRequirements, - BlockStateAccountDiff, BlockStateDiff, BlockStateDiffFetchFn, BlockStateStorageDiff, - CacheSpeedMode, CallSimulationResult, CodeMismatch, CodeSeedState, CodeVerifyReport, - DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES, DurableCheckpointBlock, DurableCheckpointError, - DurableCheckpointIdentity, DurableCheckpointMetadata, DurableCheckpointStore, EvmCache, - EvmCacheBuilder, EvmOverlay, EvmSnapshot, LoadedDurableCheckpoint, PrewarmReport, + AccessListFetchFn, AccountFieldsFetchFn, AccountProof, AccountProofFetchFn, + BlockContextRequirements, BlockStateAccountDiff, BlockStateDiff, BlockStateDiffFetchFn, + BlockStateStorageDiff, CacheSpeedMode, CallSimulationResult, CodeMismatch, CodeSeedState, + CodeVerifyReport, DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES, DurableCheckpointBlock, + DurableCheckpointError, DurableCheckpointIdentity, DurableCheckpointMetadata, + DurableCheckpointStore, EvmCache, EvmCacheBuilder, EvmOverlay, EvmSnapshot, + LoadedDurableCheckpoint, PrewarmReport, ReadSetHydrationReport, ReadSetWarmupBatch, + ReadSetWarmupCall, ReadSetWarmupConfig, ReadSetWarmupReport, ReadSetWarmupStrategy, StorageBatchConfig, StorageFetchStrategy, TxConfig, account_proof_fetcher, point_read_storage_fetcher, provider_storage_fetcher, }; diff --git a/src/reactive/mod.rs b/src/reactive/mod.rs index 0e25ac1..9d1b453 100644 --- a/src/reactive/mod.rs +++ b/src/reactive/mod.rs @@ -26,7 +26,7 @@ use std::{ Arc, atomic::{AtomicU64, Ordering}, }, - time::Duration, + time::{Duration, Instant}, }; use alloy_consensus::{BlockHeader as _, Transaction as _}; @@ -39,14 +39,15 @@ use alloy_network::{ }, }; use alloy_primitives::{Address, B256, Bytes, FixedBytes, Keccak256, U256}; -use alloy_provider::Provider; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_client::BatchRequest; use alloy_rpc_types_eth::{Filter, FilterSet, Log}; pub use alloy_transport_balancer::EndpointId; use bincode::Options; use futures::{StreamExt, stream}; use futures::{ future::{Either, poll_fn, select}, - stream::BoxStream, + stream::{BoxStream, FuturesUnordered}, }; use crate::{ @@ -137,36 +138,66 @@ pub struct FlashblockRef { pub provider: ProviderRef, /// Sequencer payload id shared by every Flashblock in the full block. /// - /// OP RPCs that expose only the standard pending block surface may not - /// expose this Base-native identifier. + /// Some provider wire shapes omit this indexed-payload identifier. pub payload_id: Option>, /// Zero-based Flashblock index, when exposed by the endpoint. pub index: Option, /// Pending block number represented by this cumulative snapshot. pub block_number: u64, - /// Hash of the cumulative partial block at this snapshot. - pub block_hash: B256, + /// Provider-generation-scoped commitment to this exact cumulative view. + /// + /// This is deliberately not a canonical or provider-reported block hash. + /// It remains non-zero even when a pending endpoint uses the zero hash + /// placeholder permitted by the Flashblocks specification. + pub content_hash: B256, + /// Non-placeholder partial block hash reported by the provider, when any. + pub partial_block_hash: Option, /// Canonical parent of the pending block, when exposed. pub parent_hash: Option, /// State root after this cumulative snapshot, when exposed. pub state_root: Option, + /// Transaction-trie root committed by a cumulative block-shaped preview. + pub transactions_root: Option, + /// Ordered cumulative transaction membership for this preview. + pub transaction_hashes: Vec, /// Pending block timestamp, when exposed. pub timestamp: Option, + /// Pending EIP-1559 base fee, when exposed. + pub base_fee_per_gas: Option, + /// Pending block beneficiary / fee recipient, when exposed. + pub beneficiary: Option
, + /// Pending block randomness value, when exposed. + pub prevrandao: Option, + /// Pending block gas limit, when exposed. + pub gas_limit: Option, } impl FlashblockRef { /// Convert the pre-confirmed identity into the block metadata used by - /// ordinary log routing. The hash is explicitly a partial/pending hash and + /// ordinary log routing. The hash is the provider-generation-scoped + /// [`content_hash`](Self::content_hash), never a canonical block hash, and /// must not advance canonical coverage. pub const fn block_ref(&self) -> BlockRef { BlockRef { number: self.block_number, - hash: self.block_hash, + hash: self.content_hash, parent_hash: self.parent_hash, timestamp: self.timestamp, } } + /// Whether the cumulative preview contains `transaction_hash`. + pub fn contains_transaction(&self, transaction_hash: &B256) -> bool { + self.transaction_hashes.contains(transaction_hash) + } + + fn transaction_index(&self, transaction_hash: &B256) -> Option { + self.transaction_hashes + .iter() + .position(|candidate| candidate == transaction_hash) + .and_then(|index| u64::try_from(index).ok()) + } + fn same_payload(&self, other: &Self) -> bool { self.provider == other.provider && match (self.payload_id, other.payload_id) { @@ -176,6 +207,18 @@ impl FlashblockRef { } } } + + fn is_cumulative_successor_of(&self, previous: &Self) -> bool { + self.same_payload(previous) + && self.transaction_hashes.len() >= previous.transaction_hashes.len() + && self + .transaction_hashes + .starts_with(&previous.transaction_hashes) + && match (previous.index, self.index) { + (Some(previous), Some(current)) => current >= previous, + _ => true, + } + } } /// Whether the subscriber may use Flashblocks for speculative delivery. @@ -191,7 +234,7 @@ pub enum PreconfirmationMode { Required, } -/// Base-native `newFlashblocks` subscription payload. +/// Indexed OP Stack `newFlashblocks` subscription payload. #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] pub struct BaseFlashblockPayload { /// Block-builder payload id shared by every incremental snapshot. @@ -218,6 +261,23 @@ pub struct BaseFlashblockBase { /// Pending block timestamp. #[serde(deserialize_with = "deserialize_rpc_u64")] pub timestamp: u64, + /// Pending block gas limit. + #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")] + pub gas_limit: Option, + /// Pending EIP-1559 base fee. + #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")] + pub base_fee_per_gas: Option, + /// Pending block beneficiary / fee recipient. + #[serde(default, alias = "fee_recipient", alias = "feeRecipient")] + pub beneficiary: Option
, + /// Pending block randomness value. + #[serde( + default, + alias = "prev_randao", + alias = "prevRandao", + alias = "mixHash" + )] + pub prevrandao: Option, } /// Stable commitment subset from Base's Flashblocks wire format. @@ -227,6 +287,12 @@ pub struct BaseFlashblockDiff { pub state_root: B256, /// Partial block hash after this cumulative snapshot. pub block_hash: B256, + /// Transactions added by this indexed Flashblock diff. + #[serde(default)] + pub transactions: Vec, + /// Transaction root when exposed by the provider. + #[serde(default)] + pub transactions_root: Option, } /// Stable metadata subset used when index-greater-than-zero payloads omit the @@ -238,8 +304,8 @@ pub struct BaseFlashblockMetadata { pub block_number: u64, } -/// Current Base/QuickNode `newFlashblocks` wire shape. The endpoint emits a -/// cumulative block-shaped snapshot for every partial block update. +/// Cumulative block-shaped `newFlashblocks` wire shape used by some OP Stack +/// providers. #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] #[serde(rename_all = "camelCase")] struct BaseFlashblockBlockPayload { @@ -248,11 +314,23 @@ struct BaseFlashblockBlockPayload { number: u64, parent_hash: B256, state_root: B256, + #[serde(default)] + transactions_root: Option, + #[serde(default)] + transactions: Vec, #[serde(deserialize_with = "deserialize_rpc_u64")] timestamp: u64, + #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")] + base_fee_per_gas: Option, + #[serde(default, alias = "beneficiary", alias = "feeRecipient")] + miner: Option
, + #[serde(default, alias = "prevRandao")] + mix_hash: Option, + #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")] + gas_limit: Option, } -/// Base has exposed both an indexed diff envelope and a cumulative +/// OP Stack providers expose either an indexed diff envelope or a cumulative /// block-shaped envelope for `newFlashblocks`. Accept both so provider rollout /// differences do not force callers onto separate subscriber paths. #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] @@ -282,6 +360,179 @@ where } } +fn deserialize_optional_rpc_u64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum RpcU64 { + Number(u64), + String(String), + } + + let Some(value) = as serde::Deserialize>::deserialize(deserializer)? else { + return Ok(None); + }; + match value { + RpcU64::Number(number) => Ok(Some(number)), + RpcU64::String(value) => { + let value = value.strip_prefix("0x").unwrap_or(&value); + u64::from_str_radix(value, 16) + .map(Some) + .map_err(serde::de::Error::custom) + } + } +} + +fn non_placeholder_hash(hash: B256) -> Option { + (!hash.is_zero()).then_some(hash) +} + +fn flashblock_transaction_hashes( + transactions: &[serde_json::Value], +) -> Result, SubscriberError> { + let hashes: Vec = transactions + .iter() + .map(|transaction| { + let value = match transaction { + serde_json::Value::String(value) => value.as_str(), + serde_json::Value::Object(object) => object + .get("hash") + .or_else(|| object.get("transactionHash")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + SubscriberError::Provider( + "Flashblock transaction object is missing its hash".into(), + ) + })?, + _ => { + return Err(SubscriberError::Provider( + "Flashblock transaction must be a hash, raw transaction, or object".into(), + )); + } + }; + if value.len() == 66 { + return value.parse::().map_err(|error| { + SubscriberError::Provider(format!( + "Flashblock transaction hash is invalid: {error}" + )) + }); + } + let encoded = value.strip_prefix("0x").unwrap_or(value); + let raw = alloy_primitives::hex::decode(encoded).map_err(|error| { + SubscriberError::Provider(format!( + "Flashblock raw transaction is invalid hex: {error}" + )) + })?; + Ok(alloy_primitives::keccak256(raw)) + }) + .collect::>()?; + let mut unique = HashSet::with_capacity(hashes.len()); + if hashes.iter().any(|hash| !unique.insert(*hash)) { + return Err(SubscriberError::Provider( + "Flashblock cumulative transaction membership contains a duplicate hash".into(), + )); + } + Ok(hashes) +} + +struct FlashblockContentCommitment<'a> { + provider: &'a ProviderRef, + payload_id: Option>, + index: Option, + block_number: u64, + partial_block_hash: Option, + parent_hash: Option, + state_root: Option, + transactions_root: Option, + transaction_hashes: &'a [B256], + timestamp: Option, + base_fee_per_gas: Option, + beneficiary: Option
, + prevrandao: Option, + gas_limit: Option, +} + +fn flashblock_content_hash(content: FlashblockContentCommitment<'_>) -> B256 { + let mut commitment = Keccak256::new(); + commitment.update(b"evm-fork-cache/flashblock-content/v1"); + let endpoint = content.provider.endpoint.as_str().as_bytes(); + commitment.update((endpoint.len() as u64).to_be_bytes()); + commitment.update(endpoint); + commitment.update(content.provider.generation.to_be_bytes()); + commitment.update(content.block_number.to_be_bytes()); + commit_optional_bytes( + &mut commitment, + content.payload_id.as_ref().map(FixedBytes::as_slice), + ); + commit_optional_u64(&mut commitment, content.index); + commit_optional_bytes( + &mut commitment, + content + .partial_block_hash + .as_ref() + .map(FixedBytes::as_slice), + ); + commit_optional_bytes( + &mut commitment, + content.parent_hash.as_ref().map(FixedBytes::as_slice), + ); + commit_optional_bytes( + &mut commitment, + content.state_root.as_ref().map(FixedBytes::as_slice), + ); + commit_optional_bytes( + &mut commitment, + content.transactions_root.as_ref().map(FixedBytes::as_slice), + ); + commitment.update((content.transaction_hashes.len() as u64).to_be_bytes()); + for transaction_hash in content.transaction_hashes { + commitment.update(transaction_hash); + } + commit_optional_u64(&mut commitment, content.timestamp); + commit_optional_u64(&mut commitment, content.base_fee_per_gas); + commit_optional_bytes( + &mut commitment, + content + .beneficiary + .as_ref() + .map(|address| address.as_slice()), + ); + commit_optional_bytes( + &mut commitment, + content.prevrandao.as_ref().map(FixedBytes::as_slice), + ); + commit_optional_u64(&mut commitment, content.gas_limit); + let hash = commitment.finalize(); + if hash.is_zero() { + B256::with_last_byte(1) + } else { + hash + } +} + +fn commit_optional_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) { + match value { + Some(value) => { + commitment.update([1]); + commitment.update((value.len() as u64).to_be_bytes()); + commitment.update(value); + } + None => commitment.update([0]), + } +} + +fn commit_optional_u64(commitment: &mut Keccak256, value: Option) { + match value { + Some(value) => { + commitment.update([1]); + commitment.update(value.to_be_bytes()); + } + None => commitment.update([0]), + } +} + /// Exact chain/block identity of an RPC cache snapshot adopted as the starting /// point for reactive event continuity. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -512,8 +763,10 @@ pub enum ChainStatus { /// Handlers may update the runtime's speculative overlay for this status, /// but the update never advances canonical coverage or durable journals. Preconfirmed { - /// Exact cumulative pre-confirmation snapshot observed by the source. - flashblock: FlashblockRef, + /// Shared exact cumulative pre-confirmation snapshot observed by the + /// source. Sharing keeps ordinary canonical records compact and makes + /// multi-log Flashblock delivery cheap to clone. + flashblock: Arc, }, /// The input is included in a block with a confirmation count. Included { @@ -995,8 +1248,7 @@ impl ReactiveInputRecord { if !self.same_deduplicable_payload(other) || !self.dedupe_context_is_compatible(other) { return Err(ReactiveError::InvalidInputRecord { message: format!( - "conflicting payload or semantic context for identity {:?}", - identity + "conflicting payload or semantic context for identity {identity:?}" ), }); } @@ -4592,15 +4844,17 @@ impl ReactiveRuntime { ), }); } - if active.flashblock.index == incoming.index - && active.flashblock.block_hash != incoming.block_hash + if active.flashblock.index.is_some() + && active.flashblock.index == incoming.index + && active.flashblock.content_hash != incoming.content_hash { + self.discard_preconfirmed_branch(cache); return Err(ReactiveError::InvalidInputRecord { - message: - "same Flashblock payload/index carried conflicting partial block hashes" - .into(), + message: "same Flashblock payload/index carried conflicting cumulative content" + .into(), }); } + install_preconfirmed_cache_context(cache, incoming); return Ok(()); } @@ -4609,6 +4863,7 @@ impl ReactiveRuntime { flashblock: incoming.clone(), canonical_cache: EvmCacheStateSnapshot::capture(cache), }); + install_preconfirmed_cache_context(cache, incoming); Ok(()) } @@ -6034,6 +6289,15 @@ impl ReactiveRuntime { } } +fn install_preconfirmed_cache_context(cache: &mut EvmCache, flashblock: &FlashblockRef) { + cache.set_block(BlockId::pending()); + cache.set_block_context(Some(flashblock.block_number), flashblock.base_fee_per_gas); + cache.set_coinbase(flashblock.beneficiary); + cache.set_prevrandao(flashblock.prevrandao); + cache.set_block_gas_limit(flashblock.gas_limit); + cache.set_timestamp(flashblock.timestamp); +} + /// Validate one provider-neutral delivery envelope without mutating runtime or /// cache state. /// @@ -7855,12 +8119,15 @@ fn batch_preconfirmation( message: "pre-confirmed input requires pre-confirmed delivery scope".into(), }); } - if flashblock.as_ref().is_some_and(|known| known != current) { + if flashblock + .as_ref() + .is_some_and(|known| known != current.as_ref()) + { return Err(ReactiveError::InvalidInputRecord { message: "one batch cannot mix distinct Flashblock snapshots".into(), }); } - flashblock.get_or_insert_with(|| current.clone()); + flashblock.get_or_insert_with(|| current.as_ref().clone()); } _ => has_non_preconfirmed = true, } @@ -9560,14 +9827,14 @@ pub enum SubscriberMode { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FlashblocksAdapter { - BaseNative, - OpPending, + NativeSubscriptions, + PendingStatePolling, } fn flashblocks_adapter(chain_id: u64) -> Option { match chain_id { - 8_453 | 84_532 => Some(FlashblocksAdapter::BaseNative), - 10 | 11_155_420 => Some(FlashblocksAdapter::OpPending), + 8_453 | 84_532 => Some(FlashblocksAdapter::NativeSubscriptions), + 10 | 11_155_420 => Some(FlashblocksAdapter::PendingStatePolling), _ => None, } } @@ -9578,9 +9845,41 @@ pub struct SubscriberConfig { /// Flashblocks delivery policy. Provider support itself is configured by /// the transport's single `flashblocks` endpoint flag. pub preconfirmations: PreconfirmationMode, - /// OP pending-state sampling cadence. Base uses native `newFlashblocks` - /// plus `pendingLogs` subscriptions instead. + /// Cadence for certifying sealed canonical heads while connected to a + /// Flashblocks endpoint whose `newHeads` stream may contain partial heads. + pub canonical_head_poll_interval: Duration, + /// Optimism pending-state sampling cadence. + /// + /// Base uses native `newFlashblocks` plus `pendingLogs`. Optimism providers + /// currently expose the interoperable Flashblocks surface through + /// `pending` RPC reads, so one generation-pinned sampler reads the + /// cumulative pending block, its exact hash-addressed parent, filtered + /// pending-block logs, and bounded exact transaction receipts. pub flashblock_poll_interval: Duration, + /// Consecutive pending-state request failure allowance. + /// + /// A successful sampling tick resets this counter. Semantic integrity + /// failures, such as non-monotonic transaction membership or malformed + /// logs, are never retried through this allowance. + pub max_consecutive_flashblock_poll_failures: usize, + /// Maximum pending receipts per sampling tick. + /// + /// Receipts are requested by exact transaction hash in one JSON-RPC batch, + /// because separate `eth_getBlockReceipts("pending")` responses can refer + /// to a different cumulative Flashblock. The rolling total-method budget + /// may impose a lower effective per-tick limit; with the defaults and one + /// log filter, at most seven receipts are requested per tick. + pub max_pending_transaction_receipts_per_tick: usize, + /// Pending-state RPC method budget per rolling one-second window. + /// + /// The sampler reserves capacity for the pending-block, exact-parent, and + /// filtered-log methods implied by its cadence and filter plan, plus the + /// exact-parent canonical-head poll when block interests require it. Exact + /// receipt hydration uses only an evenly apportioned remainder. Request + /// timestamps enforce the ceiling across actual ticks, including delayed + /// ticks. The default leaves headroom below common paid-provider limits of + /// 50 requests per second. + pub max_flashblock_rpc_requests_per_second: usize, /// Hydrate pending transaction hashes into full bodies when possible. pub hydrate_pending_transactions: bool, /// Verify each canonical log's block identity through RPC and enrich its @@ -9621,7 +9920,11 @@ impl Default for SubscriberConfig { fn default() -> Self { Self { preconfirmations: PreconfirmationMode::Disabled, - flashblock_poll_interval: Duration::from_millis(100), + canonical_head_poll_interval: Duration::from_millis(500), + flashblock_poll_interval: Duration::from_millis(250), + max_consecutive_flashblock_poll_failures: 10, + max_pending_transaction_receipts_per_tick: 32, + max_flashblock_rpc_requests_per_second: 40, hydrate_pending_transactions: false, verify_log_block_context: false, max_batch_size: 1024, @@ -9635,6 +9938,150 @@ impl Default for SubscriberConfig { } } +/// Provider surface established for one Flashblocks generation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum FlashblocksDelivery { + /// Native `newFlashblocks` plus filtered `pendingLogs` WebSocket streams. + NativeSubscriptions, + /// Generation-pinned `pending` block and log sampling. + PendingStatePolling, +} + +/// Request/response traffic issued by one Flashblocks subscriber generation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FlashblocksRpcMetrics { + capability_requests: u64, + provider_pair_chain_requests: u64, + canonical_head_requests: u64, + pending_block_requests: u64, + pending_log_requests: u64, + pending_receipt_requests: u64, + pending_receipts_completed: u64, + pending_receipts_unavailable: u64, + failed_requests: u64, + raced_samples: u64, +} + +impl FlashblocksRpcMetrics { + /// Opportunistic `op_supportedCapabilities` probes attempted. + pub const fn capability_requests(self) -> u64 { + self.capability_requests + } + + /// Chain-identity requests used to verify an explicitly paired + /// pending-state provider against the subscriber's stream provider. + pub const fn provider_pair_chain_requests(self) -> u64 { + self.provider_pair_chain_requests + } + + /// Exact parent-block requests used to fence pending and canonical state. + pub const fn canonical_head_requests(self) -> u64 { + self.canonical_head_requests + } + + /// Cumulative pending-block requests. + pub const fn pending_block_requests(self) -> u64 { + self.pending_block_requests + } + + /// Pending log-filter requests. + pub const fn pending_log_requests(self) -> u64 { + self.pending_log_requests + } + + /// Pending-state `eth_getTransactionReceipt` methods issued by exact hash. + /// Several methods may share one JSON-RPC batch transport request. + pub const fn pending_receipt_requests(self) -> u64 { + self.pending_receipt_requests + } + + /// Exact pending transaction receipts returned successfully. + pub const fn pending_receipts_completed(self) -> u64 { + self.pending_receipts_completed + } + + /// Exact pending transaction receipts that were not materialized yet and remain + /// eligible for retry on the next cumulative sample. + pub const fn pending_receipts_unavailable(self) -> u64 { + self.pending_receipts_unavailable + } + + /// Provider request failures observed by a pending-state sampler. + pub const fn failed_requests(self) -> u64 { + self.failed_requests + } + + /// Samples discarded because the pending-log response advanced beyond + /// the separately fetched cumulative block. The next tick retries from a + /// fresh block/log pair; no partial speculative view is published. + pub const fn raced_samples(self) -> u64 { + self.raced_samples + } + + /// Total request/response calls attributable to Flashblocks qualification + /// and sampling. + pub const fn total_requests(self) -> u64 { + self.capability_requests + .saturating_add(self.provider_pair_chain_requests) + .saturating_add(self.canonical_head_requests) + .saturating_add(self.pending_block_requests) + .saturating_add(self.pending_log_requests) + .saturating_add(self.pending_receipt_requests) + } +} + +/// Successful initial Flashblocks endpoint preflight. +/// +/// This proves chain identity and either subscription acknowledgement for +/// Base's `newFlashblocks` plus every pool-filtered `pendingLogs` stream, or +/// method support for OP's bounded pending block/log sampler. Notification +/// liveness and an active-pool pending log remain acceptance-window checks: a +/// successful preflight alone must not qualify an endpoint for live trading. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FlashblocksPreflight { + chain_id: u64, + provider: ProviderRef, + delivery: FlashblocksDelivery, + pending_log_subscriptions: usize, + pending_log_filters: usize, + advertised_capabilities: Option, +} + +impl FlashblocksPreflight { + /// Chain identity read from the pinned provider lease. + pub const fn chain_id(&self) -> u64 { + self.chain_id + } + + /// Provider generation whose HTTP state and both WebSocket streams were + /// preflighted together. + pub const fn provider(&self) -> &ProviderRef { + &self.provider + } + + /// Provider surface selected for this chain. + pub const fn delivery(&self) -> FlashblocksDelivery { + self.delivery + } + + /// Number of acknowledged pool-filtered `pendingLogs` subscriptions. + pub const fn pending_log_subscriptions(&self) -> usize { + self.pending_log_subscriptions + } + + /// Number of provider-facing pending-log filters covered by the native or + /// sampled delivery surface. + pub const fn pending_log_filters(&self) -> usize { + self.pending_log_filters + } + + /// Opaque response from `op_supportedCapabilities`, when the provider + /// implements that optional RPC method. + pub const fn advertised_capabilities(&self) -> Option<&serde_json::Value> { + self.advertised_capabilities.as_ref() + } +} + /// WebSocket/pubsub reconnect policy. /// /// Reconnects are applied after an established subscription stream terminates. @@ -9933,6 +10380,7 @@ pub struct SubscriberInputBatch { records: Vec>, chain_id: Option, chain_controls: Vec, + preconfirmation_invalidated: bool, } /// Result of polling a scoped subscriber batch against one driver control @@ -9962,6 +10410,12 @@ impl SubscriberInputBatch { &self.chain_controls } + /// Whether the announcing Flashblocks generation lost continuity before + /// this batch was returned. + pub const fn preconfirmation_invalidated(&self) -> bool { + self.preconfirmation_invalidated + } + /// Consume the scoped subscriber delivery into a runtime-ready batch. /// /// Delivery audiences and the preconfirmed/canonical boundary are retained, @@ -11762,6 +12216,17 @@ where } } +type FlashblockReconnectFuture = Pin< + Box< + dyn Future< + Output = ( + SubscriberStreamSource, + Result>, SubscriberError>, + ), + > + Send, + >, +>; + /// Alloy-backed event subscriber. /// /// The default transport slice drives Alloy pubsub subscriptions for logs, @@ -11784,6 +12249,10 @@ where /// `Ok(None)`. pub struct AlloySubscriber { provider: P, + /// Optional request/response half of the same configured provider lease. + /// OP Flashblocks pending reads use this transport when WebSocket JSON-RPC + /// does not expose the provider's pending-state surface. + flashblocks_state_provider: Option

, /// Stable identity for the provider session used by Flashblocks and every /// follow-up pending-state read. provider_ref: Option, @@ -11838,11 +12307,26 @@ pub struct AlloySubscriber { recent_compat_owner_input_refs: HashMap>, recent_compat_owner_input_ref_sets: HashMap>, base_flashblock_header: Option<(FixedBytes<8>, BaseFlashblockBase)>, - flashblocks_by_hash: HashMap, - flashblock_hash_order: VecDeque, + base_flashblock_transactions: Option<(FixedBytes<8>, u64, Vec, Vec)>, unmatched_pending_logs: VecDeque<(usize, Log)>, latest_preconfirmation: Option, preconfirmed_seen_logs: HashSet<(B256, u64)>, + /// OP transaction receipts already proven for the active cumulative + /// payload. This avoids re-querying non-matching transactions while still + /// retrying receipts that were temporarily unavailable. + preconfirmed_receipted_transactions: HashSet, + /// OP receipt hashes that returned `null` at least once for the active + /// payload. Never-attempted hashes are scheduled ahead of this retry set so + /// a lagging provider cache cannot let a few transactions monopolize the + /// bounded request budget. + preconfirmed_unavailable_receipts: HashSet, + last_certified_canonical_head: Option, + pending_preconfirmation_invalidation: bool, + pending_flashblock_reconnects: FuturesUnordered>, + pending_flashblock_reconnect_sources: Vec, + flashblocks_rpc_metrics: FlashblocksRpcMetrics, + consecutive_flashblock_poll_failures: usize, + flashblock_rpc_request_times: VecDeque, _network: PhantomData, } @@ -11920,6 +12404,7 @@ impl AlloySubscriber { ensure_ring_crypto_provider(); Self { provider, + flashblocks_state_provider: None, provider_ref: None, log_verification_provider: None, chain_id: None, @@ -11950,11 +12435,19 @@ impl AlloySubscriber { recent_compat_owner_input_refs: HashMap::new(), recent_compat_owner_input_ref_sets: HashMap::new(), base_flashblock_header: None, - flashblocks_by_hash: HashMap::new(), - flashblock_hash_order: VecDeque::new(), + base_flashblock_transactions: None, unmatched_pending_logs: VecDeque::new(), latest_preconfirmation: None, preconfirmed_seen_logs: HashSet::new(), + preconfirmed_receipted_transactions: HashSet::new(), + preconfirmed_unavailable_receipts: HashSet::new(), + last_certified_canonical_head: None, + pending_preconfirmation_invalidation: false, + pending_flashblock_reconnects: FuturesUnordered::new(), + pending_flashblock_reconnect_sources: Vec::new(), + flashblocks_rpc_metrics: FlashblocksRpcMetrics::default(), + consecutive_flashblock_poll_failures: 0, + flashblock_rpc_request_times: VecDeque::new(), _network: PhantomData, } } @@ -11973,6 +12466,19 @@ impl AlloySubscriber { self } + /// Pair the subscriber's event transport with the request/response + /// transport for the same configured provider ID and generation. + /// + /// Optimism pending block/log sampling uses this provider. Preflight reads + /// its chain ID and rejects a mismatch before pending data can be emitted. + /// Use type-erased Alloy providers when the WebSocket and HTTP transports + /// have different concrete Rust types. + #[must_use] + pub fn with_flashblocks_state_provider(mut self, provider: P) -> Self { + self.flashblocks_state_provider = Some(provider); + self + } + /// Use a separate provider for canonical log-context verification. /// /// This is recommended with @@ -11995,6 +12501,12 @@ impl AlloySubscriber { &self.config } + /// Request/response traffic issued for Flashblocks qualification and + /// pending-state sampling since the last full interest reset. + pub const fn flashblocks_rpc_metrics(&self) -> FlashblocksRpcMetrics { + self.flashblocks_rpc_metrics + } + /// Registered interests across base and owner-scoped registrations. pub fn registered_interests(&self) -> &[ReactiveInterest] { &self.interests @@ -13091,6 +13603,7 @@ impl AlloySubscriber { } SubscriberStreamSource::BaseFlashblocks | SubscriberStreamSource::OpPendingFlashblocks + | SubscriberStreamSource::CanonicalHeadPolling | SubscriberStreamSource::PubSubPendingHashes | SubscriberStreamSource::PubSubBlockHeaders | SubscriberStreamSource::PollingPendingHashes => {} @@ -13105,7 +13618,10 @@ impl AlloySubscriber { } fn drain_next_scoped_batch(&mut self) -> Option> { - if self.pending_records.is_empty() && self.pending_chain_controls.is_empty() { + if self.pending_records.is_empty() + && self.pending_chain_controls.is_empty() + && !self.pending_preconfirmation_invalidation + { return None; } @@ -13143,6 +13659,9 @@ impl AlloySubscriber { records, chain_id: self.chain_id, chain_controls, + preconfirmation_invalidated: std::mem::take( + &mut self.pending_preconfirmation_invalidation, + ), }) } @@ -13162,19 +13681,42 @@ impl AlloySubscriber { self.recent_compat_owner_input_ref_sets.clear(); self.pending_backfills.clear(); self.pending_source_backfills.clear(); + self.pending_preconfirmation_invalidation = false; + self.pending_flashblock_reconnects.clear(); + self.pending_flashblock_reconnect_sources.clear(); + self.flashblocks_rpc_metrics = FlashblocksRpcMetrics::default(); self.log_source_ids.clear(); self.next_log_source_id = 0; self.sources_dirty = true; + self.last_certified_canonical_head = None; self.reset_flashblock_tracking(); } fn reset_flashblock_tracking(&mut self) { self.base_flashblock_header = None; - self.flashblocks_by_hash.clear(); - self.flashblock_hash_order.clear(); + self.base_flashblock_transactions = None; self.unmatched_pending_logs.clear(); self.latest_preconfirmation = None; self.preconfirmed_seen_logs.clear(); + self.preconfirmed_receipted_transactions.clear(); + self.preconfirmed_unavailable_receipts.clear(); + self.consecutive_flashblock_poll_failures = 0; + } + + /// Revoke only the active speculative snapshot while keeping the pinned + /// provider session and its streams alive. A sampled OP pending view can + /// legitimately be replaced, or a provider backend can briefly return an + /// older cumulative view. Either observation makes the current signing + /// authority unsafe, but does not prove that the transport generation is + /// broken and should be reconnected. + fn invalidate_preconfirmation_snapshot(&mut self) { + self.pending_records + .retain(|record| record.scope != SubscriberInputScope::Preconfirmed); + self.pending_preconfirmation_invalidation = true; + self.latest_preconfirmation = None; + self.preconfirmed_seen_logs.clear(); + self.preconfirmed_receipted_transactions.clear(); + self.preconfirmed_unavailable_receipts.clear(); } fn bump_stream_revision(&mut self) { @@ -13410,6 +13952,7 @@ enum SubscriberStreamSource { BasePendingLog { id: usize, filter: Filter }, BaseFlashblocks, OpPendingFlashblocks, + CanonicalHeadPolling, PubSubPendingHashes, PubSubBlockHeaders, PollingLog { filter: Filter }, @@ -13420,9 +13963,10 @@ impl SubscriberStreamSource { fn label(&self) -> &'static str { match self { Self::PubSubLog { .. } => "pubsub log", - Self::BasePendingLog { .. } => "Base pendingLogs", - Self::BaseFlashblocks => "Base newFlashblocks", - Self::OpPendingFlashblocks => "OP pending Flashblocks", + Self::BasePendingLog { .. } => "OP Stack pendingLogs", + Self::BaseFlashblocks => "OP Stack newFlashblocks", + Self::OpPendingFlashblocks => "Optimism pending Flashblocks", + Self::CanonicalHeadPolling => "certified canonical head", Self::PubSubPendingHashes => "pubsub pending transaction hash", Self::PubSubBlockHeaders => "pubsub block header", Self::PollingLog { .. } => "polling log", @@ -13436,6 +13980,7 @@ impl SubscriberStreamSource { Self::PubSubLog { .. } | Self::BasePendingLog { .. } | Self::BaseFlashblocks + | Self::OpPendingFlashblocks | Self::PubSubPendingHashes | Self::PubSubBlockHeaders ) @@ -13460,6 +14005,7 @@ impl SubscriberStreamSource { } (Self::BaseFlashblocks, Self::BaseFlashblocks) | (Self::OpPendingFlashblocks, Self::OpPendingFlashblocks) + | (Self::CanonicalHeadPolling, Self::CanonicalHeadPolling) | (Self::PubSubPendingHashes, Self::PubSubPendingHashes) | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders) | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true, @@ -13488,14 +14034,148 @@ enum SubscriberEvent { }, BaseFlashblock(BaseFlashblockWirePayload), OpFlashblockTick, + CanonicalHeadTick, PreconfirmedLogs { flashblock: FlashblockRef, logs: Vec, }, + FlashblockInvalidated, FlashblockObserved, StreamTerminated(SubscriberStreamSource), } +enum SubscriberReady { + Event(Option>), + FlashblockReconnect( + SubscriberStreamSource, + Result>, SubscriberError>, + ), +} + +#[derive(Debug)] +enum PendingFlashblockPollError { + Request(SubscriberError), + Integrity(SubscriberError), +} + +impl PendingFlashblockPollError { + fn into_subscriber(self) -> SubscriberError { + match self { + Self::Request(error) | Self::Integrity(error) => error, + } + } +} + +fn pending_flashblock_request_error(error: impl fmt::Display) -> PendingFlashblockPollError { + PendingFlashblockPollError::Request(provider_error(error)) +} + +fn normalize_op_pending_block( + mut value: serde_json::Value, +) -> Result { + let object = value.as_object_mut().ok_or_else(|| { + SubscriberError::Provider("OP pending block response is not an object".into()) + })?; + let transactions = object + .get_mut("transactions") + .and_then(serde_json::Value::as_array_mut) + .ok_or_else(|| { + SubscriberError::Provider( + "OP pending block response is missing its transaction array".into(), + ) + })?; + for transaction in transactions { + if transaction.is_string() { + continue; + } + let hash = transaction + .as_object() + .and_then(|object| object.get("hash")) + .filter(|hash| hash.is_string()) + .cloned() + .ok_or_else(|| { + SubscriberError::Provider("OP pending block transaction is missing its hash".into()) + })?; + *transaction = hash; + } + if object.get("hash").is_none_or(serde_json::Value::is_null) { + object.insert( + "hash".into(), + serde_json::Value::String(B256::ZERO.to_string()), + ); + } + if object.get("nonce").is_none_or(serde_json::Value::is_null) { + object.insert( + "nonce".into(), + serde_json::Value::String("0x0000000000000000".into()), + ); + } + if object.get("miner").is_none_or(serde_json::Value::is_null) + || object + .get("beneficiary") + .is_none_or(serde_json::Value::is_null) + { + object.insert( + "miner".into(), + serde_json::Value::String(Address::ZERO.to_string()), + ); + } + serde_json::from_value(value).map_err(|error| { + SubscriberError::Provider(format!( + "failed to decode normalized OP pending block: {error}" + )) + }) +} + +fn normalize_pending_transaction_receipt( + expected_transaction_hash: B256, + value: serde_json::Value, +) -> Result>, SubscriberError> { + if value.is_null() { + return Ok(None); + } + let receipt = value.as_object().ok_or_else(|| { + SubscriberError::Provider("pending transaction receipt response is not an object".into()) + })?; + let transaction_hash: B256 = + serde_json::from_value(receipt.get("transactionHash").cloned().ok_or_else(|| { + SubscriberError::Provider( + "pending transaction receipt is missing its transaction hash".into(), + ) + })?) + .map_err(|error| { + SubscriberError::Provider(format!( + "failed to decode pending transaction receipt hash: {error}" + )) + })?; + if transaction_hash != expected_transaction_hash { + return Err(SubscriberError::Provider( + "pending transaction receipt hash disagrees with its request".into(), + )); + } + let receipt_logs = receipt + .get("logs") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + SubscriberError::Provider("pending transaction receipt is missing its log array".into()) + })?; + let mut logs = Vec::new(); + for log in receipt_logs { + let log: Log = serde_json::from_value(log.clone()).map_err(|error| { + SubscriberError::Provider(format!( + "failed to decode pending transaction receipt log: {error}" + )) + })?; + if log.transaction_hash != Some(expected_transaction_hash) { + return Err(SubscriberError::Provider( + "pending transaction receipt log hash disagrees with its receipt".into(), + )); + } + logs.push(log); + } + Ok(Some(logs)) +} + impl EventSubscriber for AlloySubscriber where P: Provider + Send + Sync, @@ -13568,53 +14248,317 @@ where N: Network + 'static, N::HeaderResponse: Send + 'static, { - /// Resolve the provider's chain identity once. The assignment happens only - /// after a complete RPC response, so cancelling the future leaves the - /// subscriber cleanly retryable. - async fn ensure_chain_id(&mut self) -> Result { - if let Some(chain_id) = self.chain_id { - return Ok(chain_id); - } - let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?; - self.chain_id = Some(chain_id); - Ok(chain_id) - } - - fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> { + /// Validate one pinned OP Stack provider generation and establish its + /// chain-specific Flashblocks surface. + /// + /// The caller must register at least one active log interest first. The + /// method requires a matching chain id and stable [`ProviderRef`]. Base + /// additionally requires pubsub, `newFlashblocks`, and one `pendingLogs` + /// acknowledgement per planned provider filter. Optimism probes the + /// bounded pending block/log/receipt surface. `op_supportedCapabilities` + /// is queried opportunistically and retained as opaque evidence because + /// provider implementations do not expose a uniform capability vocabulary. + /// + /// A successful return is deliberately not a liveness qualification. The + /// acceptance window must still observe a Flashblock whose pending state + /// advances and a correlated log for an active pool. + pub async fn establish_flashblocks_preflight( + &mut self, + expected_chain_id: u64, + ) -> Result { + validate_subscriber_config(&self.config)?; if self.config.preconfirmations == PreconfirmationMode::Disabled { - return Ok(()); + return Err(SubscriberError::InvalidConfig( + "Flashblocks preflight requires preconfirmations", + )); } - if self.provider_ref.is_none() { + if !self + .interests + .iter() + .any(|interest| matches!(interest, ReactiveInterest::Logs(_))) + { return Err(SubscriberError::InvalidConfig( - "Flashblocks require a stable provider ref from a pinned provider lease", + "Flashblocks preflight requires at least one active log interest", )); } - let Some(chain_id) = self.chain_id else { - return Ok(()); - }; - match flashblocks_adapter(chain_id) { - Some(FlashblocksAdapter::BaseNative) - if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub - && self.config.preconfirmations == PreconfirmationMode::Required => - { - return Err(SubscriberError::Unsupported( - "Base Flashblocks require pubsub for newFlashblocks and pendingLogs", - )); - } - Some(_) => {} - None if self.config.preconfirmations == PreconfirmationMode::Required => { - return Err(SubscriberError::Unsupported( - "Flashblocks are currently implemented for Base and OP chains", - )); - } - None => {} + let chain_id = self.ensure_chain_id().await?; + if chain_id != expected_chain_id { + return Err(SubscriberError::ChainMismatch { + expected: expected_chain_id, + actual: chain_id, + }); } - Ok(()) - } + self.validate_flashblocks_setup()?; + let adapter = flashblocks_adapter(chain_id).ok_or(SubscriberError::Unsupported( + "Flashblocks are currently implemented for Base and OP chains", + ))?; + let provider = self + .provider_ref + .clone() + .ok_or(SubscriberError::InvalidConfig( + "Flashblocks preflight requires a stable provider ref", + ))?; + self.flashblocks_rpc_metrics.capability_requests = self + .flashblocks_rpc_metrics + .capability_requests + .saturating_add(1); + let capability_provider = if adapter == FlashblocksAdapter::PendingStatePolling { + self.flashblocks_state_provider + .as_ref() + .unwrap_or(&self.provider) + } else { + &self.provider + }; + let advertised_capabilities = capability_provider + .client() + .request::<_, serde_json::Value>("op_supportedCapabilities", ()) + .await + .ok(); - /// Subscribe first, then catch an exact staged owner up through a verified - /// canonical block. - /// + self.ensure_streams().await?; + let pending_log_filters = self.log_stream_filters(); + if adapter == FlashblocksAdapter::PendingStatePolling + && self.pending_receipt_requests_per_tick_capacity() == 0 + { + return Err(SubscriberError::InvalidConfig( + "Flashblocks RPC budget leaves no capacity for OP transaction receipts", + )); + } + let (delivery, pending_log_subscriptions) = match adapter { + FlashblocksAdapter::NativeSubscriptions => { + if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub { + return Err(SubscriberError::Unsupported( + "Base Flashblocks preflight requires pubsub", + )); + } + let pending_sources = self + .pubsub_stream_sources() + .into_iter() + .filter(|source| { + matches!(source, SubscriberStreamSource::BasePendingLog { .. }) + }) + .collect::>(); + let AlloySubscriberState::Active(streams) = &self.state else { + return Err(SubscriberError::Provider( + "Flashblocks preflight subscriptions did not become active".to_owned(), + )); + }; + if !streams.contains_source(&SubscriberStreamSource::BaseFlashblocks) + || pending_sources + .iter() + .any(|source| !streams.contains_source(source)) + { + return Err(SubscriberError::Provider( + "Base Flashblocks preflight did not retain both subscription lanes" + .to_owned(), + )); + } + ( + FlashblocksDelivery::NativeSubscriptions, + pending_sources.len(), + ) + } + FlashblocksAdapter::PendingStatePolling => { + if let Some(state_provider) = self.flashblocks_state_provider.as_ref() { + self.flashblocks_rpc_metrics.provider_pair_chain_requests = self + .flashblocks_rpc_metrics + .provider_pair_chain_requests + .saturating_add(1); + let actual = state_provider + .get_chain_id() + .await + .map_err(provider_error)?; + if actual != expected_chain_id { + return Err(SubscriberError::ChainMismatch { + expected: expected_chain_id, + actual, + }); + } + } + let AlloySubscriberState::Active(streams) = &self.state else { + return Err(SubscriberError::Provider( + "Flashblocks preflight streams did not become active".to_owned(), + )); + }; + if !streams.contains_source(&SubscriberStreamSource::OpPendingFlashblocks) { + return Err(SubscriberError::Provider( + "Optimism Flashblocks preflight did not retain its pending-state sampler" + .to_owned(), + )); + } + self.probe_pending_state(&pending_log_filters).await?; + (FlashblocksDelivery::PendingStatePolling, 0) + } + }; + Ok(FlashblocksPreflight { + chain_id, + provider, + delivery, + pending_log_subscriptions, + pending_log_filters: pending_log_filters.len(), + advertised_capabilities, + }) + } + + async fn probe_pending_state(&mut self, filters: &[Filter]) -> Result<(), SubscriberError> { + self.flashblocks_rpc_metrics.pending_block_requests = self + .flashblocks_rpc_metrics + .pending_block_requests + .saturating_add(1); + let pending = self + .fetch_op_pending_block() + .await + .map_err(PendingFlashblockPollError::into_subscriber)? + .ok_or_else(|| { + SubscriberError::Provider( + "provider returned no pending block during Flashblocks preflight".into(), + ) + })?; + self.certify_op_pending_parent(&pending) + .await + .map_err(PendingFlashblockPollError::into_subscriber)?; + for filter in filters { + self.flashblocks_rpc_metrics.pending_log_requests = self + .flashblocks_rpc_metrics + .pending_log_requests + .saturating_add(1); + self.flashblocks_state_provider + .as_ref() + .unwrap_or(&self.provider) + .get_logs( + &filter + .clone() + .from_block(BlockNumberOrTag::Latest) + .to_block(BlockNumberOrTag::Pending), + ) + .await + .map_err(provider_error)?; + } + self.flashblocks_rpc_metrics.pending_receipt_requests = self + .flashblocks_rpc_metrics + .pending_receipt_requests + .saturating_add(1); + let _: serde_json::Value = self + .flashblocks_state_provider + .as_ref() + .unwrap_or(&self.provider) + .raw_request(Cow::Borrowed("eth_getTransactionReceipt"), (B256::ZERO,)) + .await + .map_err(provider_error)?; + Ok(()) + } + + async fn certify_op_pending_parent( + &mut self, + pending: &N::BlockResponse, + ) -> Result { + let pending_header = pending.header(); + let pending_number = pending_header.number(); + let parent_hash = pending_header.parent_hash(); + if pending_number == 0 || parent_hash.is_zero() { + return Err(PendingFlashblockPollError::Integrity( + SubscriberError::Provider( + "OP pending block omitted a certifiable canonical parent".into(), + ), + )); + } + self.flashblocks_rpc_metrics.canonical_head_requests = self + .flashblocks_rpc_metrics + .canonical_head_requests + .saturating_add(1); + let parent = self + .flashblocks_state_provider + .as_ref() + .unwrap_or(&self.provider) + .get_block_by_hash(parent_hash) + .await + .map_err(pending_flashblock_request_error)? + .ok_or_else(|| { + PendingFlashblockPollError::Request(SubscriberError::Provider( + "Flashblocks provider returned no exact OP pending parent block".into(), + )) + })?; + let parent_header = parent.header(); + if parent_header.hash() != parent_hash + || parent_header.number().checked_add(1) != Some(pending_number) + { + return Err(PendingFlashblockPollError::Integrity( + SubscriberError::Provider( + "OP pending block does not extend its exact certified parent".into(), + ), + )); + } + Ok(parent_header.clone()) + } + + async fn fetch_op_pending_block( + &mut self, + ) -> Result, PendingFlashblockPollError> { + let state_provider = self + .flashblocks_state_provider + .as_ref() + .unwrap_or(&self.provider); + let value: Option = state_provider + .raw_request( + Cow::Borrowed("eth_getBlockByNumber"), + (BlockNumberOrTag::Pending, true), + ) + .await + .map_err(pending_flashblock_request_error)?; + value + .map(normalize_op_pending_block::) + .transpose() + .map_err(PendingFlashblockPollError::Integrity) + } + + /// Resolve the provider's chain identity once. The assignment happens only + /// after a complete RPC response, so cancelling the future leaves the + /// subscriber cleanly retryable. + async fn ensure_chain_id(&mut self) -> Result { + if let Some(chain_id) = self.chain_id { + return Ok(chain_id); + } + let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?; + self.chain_id = Some(chain_id); + Ok(chain_id) + } + + fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> { + if self.config.preconfirmations == PreconfirmationMode::Disabled { + return Ok(()); + } + if self.provider_ref.is_none() { + return Err(SubscriberError::InvalidConfig( + "Flashblocks require a stable provider ref from a pinned provider lease", + )); + } + let Some(chain_id) = self.chain_id else { + return Ok(()); + }; + match flashblocks_adapter(chain_id) { + Some(FlashblocksAdapter::NativeSubscriptions) + if resolve_subscriber_transport(self.mode)? != SubscriberTransport::PubSub + && self.config.preconfirmations == PreconfirmationMode::Required => + { + return Err(SubscriberError::Unsupported( + "Base Flashblocks require pubsub for newFlashblocks and pendingLogs", + )); + } + Some(FlashblocksAdapter::NativeSubscriptions) => {} + Some(_) => {} + None if self.config.preconfirmations == PreconfirmationMode::Required => { + return Err(SubscriberError::Unsupported( + "Flashblocks are currently implemented for Base and OP chains", + )); + } + None => {} + } + Ok(()) + } + + /// Subscribe first, then catch an exact staged owner up through a verified + /// canonical block. + /// /// This compatibility wrapper delegates to /// [`reconcile_interest_owners`](Self::reconcile_interest_owners), so a /// driver adopting several owners should call the bulk API once rather than @@ -14001,7 +14945,27 @@ where }; for source in missing { - let stream = self.connect_source_stream(source.clone()).await?; + let stream = match self.connect_source_stream(source.clone()).await { + Ok(stream) => stream, + Err(error) + if source.is_flashblocks() + && self.config.preconfirmations == PreconfirmationMode::Preferred => + { + tracing::warn!( + stream = source.label(), + error = %error, + "Flashblocks source unavailable; canonical delivery remains active" + ); + if self.config.reconnect.enabled { + self.schedule_flashblock_reconnect( + source, + self.config.reconnect.retry_delay, + ); + } + continue; + } + Err(error) => return Err(error), + }; // Publish each successful connection before any later await. If a // second connection or anchored catch-up fails/cancels, this stream // remains live and the next reconcile skips reconnecting it. @@ -14070,6 +15034,38 @@ where self.bump_stream_revision(); } + fn schedule_flashblock_reconnect( + &mut self, + source: SubscriberStreamSource, + first_delay: Duration, + ) { + if self + .pending_flashblock_reconnect_sources + .iter() + .any(|pending| pending.same_key(&source)) + { + return; + } + self.pending_flashblock_reconnect_sources + .push(source.clone()); + self.pending_flashblock_reconnects + .push(flashblock_reconnect_future( + self.provider.root().clone(), + source, + self.config.max_batch_size, + self.config.reconnect.clone(), + first_delay, + self.config.flashblock_poll_interval, + )); + } + + fn reschedule_preferred_flashblock(&mut self, source: SubscriberStreamSource) { + if !self.config.reconnect.enabled { + return; + } + self.schedule_flashblock_reconnect(source, self.config.reconnect.max_delay); + } + fn source_requires_backfill(&self, source: &SubscriberStreamSource) -> bool { matches!(source, SubscriberStreamSource::PubSubLog { id, .. } if self.last_seen_log_blocks.contains_key(id)) @@ -14250,19 +15246,25 @@ where } if needs_header_block_stream(&self.interests) { - sources.push(SubscriberStreamSource::PubSubBlockHeaders); + if self.config.preconfirmations != PreconfirmationMode::Disabled + && self.chain_id.and_then(flashblocks_adapter).is_some() + { + sources.push(SubscriberStreamSource::CanonicalHeadPolling); + } else { + sources.push(SubscriberStreamSource::PubSubBlockHeaders); + } } if self.config.preconfirmations != PreconfirmationMode::Disabled { match self.chain_id.and_then(flashblocks_adapter) { - Some(FlashblocksAdapter::BaseNative) => { + Some(FlashblocksAdapter::NativeSubscriptions) => { sources.push(SubscriberStreamSource::BaseFlashblocks); for filter in self.log_stream_filters() { let id = self.log_source_id(&filter); sources.push(SubscriberStreamSource::BasePendingLog { id, filter }); } } - Some(FlashblocksAdapter::OpPending) => { + Some(FlashblocksAdapter::PendingStatePolling) => { sources.push(SubscriberStreamSource::OpPendingFlashblocks); } None => {} @@ -14284,7 +15286,8 @@ where } if self.config.preconfirmations != PreconfirmationMode::Disabled - && self.chain_id.and_then(flashblocks_adapter) == Some(FlashblocksAdapter::OpPending) + && self.chain_id.and_then(flashblocks_adapter) + == Some(FlashblocksAdapter::PendingStatePolling) { sources.push(SubscriberStreamSource::OpPendingFlashblocks); } @@ -14318,6 +15321,9 @@ where SubscriberStreamSource::OpPendingFlashblocks => { self.connect_op_flashblock_tick_stream() } + SubscriberStreamSource::CanonicalHeadPolling => { + self.connect_canonical_head_tick_stream() + } SubscriberStreamSource::PubSubPendingHashes => { self.connect_pubsub_pending_hash_stream().await } @@ -14423,10 +15429,28 @@ where } } + fn connect_canonical_head_tick_stream( + &self, + ) -> Result>, SubscriberError> { + let mut interval = tokio::time::interval(self.config.canonical_head_poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let stream = stream::unfold(interval, |mut interval| async move { + interval.tick().await; + Some((SubscriberEvent::CanonicalHeadTick, interval)) + }); + Ok(stream_with_termination( + stream, + SubscriberStreamSource::CanonicalHeadPolling, + )) + } + fn connect_op_flashblock_tick_stream( &self, ) -> Result>, SubscriberError> { - let interval = tokio::time::interval(self.config.flashblock_poll_interval); + let first_tick = tokio::time::Instant::now() + self.config.flashblock_poll_interval; + let mut interval = + tokio::time::interval_at(first_tick, self.config.flashblock_poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let stream = stream::unfold(interval, |mut interval| async move { interval.tick().await; Some((SubscriberEvent::OpFlashblockTick, interval)) @@ -14549,13 +15573,68 @@ where async fn next_event(&mut self) -> Result>, SubscriberError> { loop { - let event = match &mut self.state { - AlloySubscriberState::Active(streams) => streams.next().await, + let ready = match &mut self.state { + AlloySubscriberState::Active(streams) + if !self.pending_flashblock_reconnects.is_empty() => + { + let stream_event = Box::pin(streams.next()); + let reconnect = Box::pin(self.pending_flashblock_reconnects.next()); + match select(reconnect, stream_event).await { + Either::Left((reconnect, pending_event)) => { + drop(pending_event); + let Some((source, result)) = reconnect else { + continue; + }; + SubscriberReady::FlashblockReconnect(source, result) + } + Either::Right((event, pending_reconnect)) => { + drop(pending_reconnect); + SubscriberReady::Event(event) + } + } + } + AlloySubscriberState::Active(streams) => { + SubscriberReady::Event(streams.next().await) + } + AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty + if !self.pending_flashblock_reconnects.is_empty() => + { + let Some((source, result)) = self.pending_flashblock_reconnects.next().await + else { + continue; + }; + SubscriberReady::FlashblockReconnect(source, result) + } AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => { return Ok(None); } }; + let event = match ready { + SubscriberReady::Event(event) => event, + SubscriberReady::FlashblockReconnect(source, result) => { + self.pending_flashblock_reconnect_sources + .retain(|pending| !pending.same_key(&source)); + match result { + Ok(stream) => { + self.install_source_stream(source, stream); + } + Err(error) + if self.config.preconfirmations == PreconfirmationMode::Preferred => + { + tracing::warn!( + stream = source.label(), + error = %error, + "Flashblocks reconnect window exhausted; canonical delivery remains active" + ); + self.reschedule_preferred_flashblock(source); + } + Err(error) => return Err(error), + } + continue; + } + }; + let Some(event) = event else { return Err(SubscriberError::Provider( "Alloy subscriber streams terminated before the subscriber was stopped" @@ -14568,11 +15647,12 @@ where // Persist the missing-source intent before the first await. // If a control command cancels this poll during reconnect, // the next poll will reconcile the desired/live diff. - self.sources_dirty = true; - self.bump_stream_revision(); if source.is_flashblocks() { - self.reset_flashblock_tracking(); + self.invalidate_flashblock_generation(); + return Ok(Some(SubscriberEvent::FlashblockInvalidated)); } + self.sources_dirty = true; + self.bump_stream_revision(); if let Some(backfill_event) = self.reconnect_source_stream(source).await? { self.sources_dirty = false; if let Some(backfill_event) = @@ -14595,21 +15675,70 @@ where } } + fn invalidate_flashblock_generation(&mut self) { + self.pending_records + .retain(|record| record.scope != SubscriberInputScope::Preconfirmed); + self.pending_preconfirmation_invalidation = true; + self.reset_flashblock_tracking(); + if let Some(provider) = self.provider_ref.as_mut() { + provider.generation = provider.generation.saturating_add(1); + } + if let AlloySubscriberState::Active(streams) = &mut self.state { + streams + .entries + .retain(|entry| !entry.source.is_flashblocks()); + streams.normalize_next_index(); + } + let reconnect_sources = self + .stream_sources() + .unwrap_or_default() + .into_iter() + .filter(SubscriberStreamSource::is_flashblocks) + .collect::>(); + self.pending_flashblock_reconnects.clear(); + self.pending_flashblock_reconnect_sources.clear(); + if self.config.preconfirmations == PreconfirmationMode::Required + || self.config.reconnect.enabled + { + for source in reconnect_sources { + self.schedule_flashblock_reconnect(source, self.config.reconnect.initial_delay); + } + } + self.sources_dirty = false; + self.bump_stream_revision(); + } + async fn normalize_flashblock_event( &mut self, event: SubscriberEvent, ) -> Result>, SubscriberError> { match event { SubscriberEvent::BasePendingLog { source_id, log } => { - let hash = log.block_hash.ok_or_else(|| { + let block_number = log.block_number.ok_or_else(|| { + SubscriberError::Provider( + "pendingLogs item is missing its pending block number".into(), + ) + })?; + let transaction_hash = log.transaction_hash.ok_or_else(|| { SubscriberError::Provider( - "Base pendingLogs item is missing its partial block hash".into(), + "pendingLogs item is missing its transaction hash".into(), ) })?; - let Some(flashblock) = self.flashblocks_by_hash.get(&hash).cloned() else { + let matching = self.latest_preconfirmation.as_ref().filter(|flashblock| { + flashblock.block_number == block_number + && flashblock.contains_transaction(&transaction_hash) + }); + let Some(flashblock) = matching.cloned() else { + if self + .latest_preconfirmation + .as_ref() + .is_some_and(|latest| block_number < latest.block_number) + { + return Ok(None); + } if self.unmatched_pending_logs.len() >= self.config.max_pending_records { return Err(SubscriberError::ResourceExhausted( - "unmatched Base pendingLogs exceeded max_pending_records".into(), + "unmatched pendingLogs exceeded max_pending_records".into(), )); } self.unmatched_pending_logs.push_back((source_id, log)); @@ -14628,19 +15757,51 @@ where let mut logs = Vec::new(); let mut retained = VecDeque::new(); while let Some((source_id, log)) = self.unmatched_pending_logs.pop_front() { - if log.block_hash == Some(flashblock.block_hash) { + let transaction_hash = log.transaction_hash; + if log.block_number == Some(flashblock.block_number) + && transaction_hash + .as_ref() + .is_some_and(|hash| flashblock.contains_transaction(hash)) + { let _ = source_id; logs.push(log); - } else { + } else if log + .block_number + .is_some_and(|number| number >= flashblock.block_number) + { retained.push_back((source_id, log)); + } else { + // A late log for an older speculative block can no + // longer be applied to the active cumulative branch. } } self.unmatched_pending_logs = retained; - if recover_pending_snapshot - && let Some(event) = self.fetch_pending_flashblock().await? - { - return Ok(Some(event)); + let indexed_recovery = recover_pending_snapshot.then(|| { + let payload_id = flashblock + .payload_id + .expect("indexed recovery carries a payload id"); + let index = flashblock.index.expect("indexed recovery carries an index"); + let last_diff = self + .base_flashblock_transactions + .as_ref() + .filter(|(known_payload, known_index, _, _)| { + *known_payload == payload_id && *known_index == index + }) + .map(|(_, _, _, last_diff)| last_diff.clone()) + .unwrap_or_default(); + (payload_id, index, last_diff) + }); + if recover_pending_snapshot { + if let Some(event) = self + .fetch_pending_flashblock(indexed_recovery) + .await + .map_err(PendingFlashblockPollError::into_subscriber)? + { + return Ok(Some(event)); + } + self.invalidate_flashblock_generation(); + return Ok(Some(SubscriberEvent::FlashblockInvalidated)); } let logs = self.filter_preconfirmed_logs(&flashblock, logs)?; Ok(Some(if logs.is_empty() { @@ -14649,7 +15810,8 @@ where SubscriberEvent::PreconfirmedLogs { flashblock, logs } })) } - SubscriberEvent::OpFlashblockTick => self.fetch_pending_flashblock().await, + SubscriberEvent::OpFlashblockTick => self.poll_op_pending_flashblock().await, + SubscriberEvent::CanonicalHeadTick => self.fetch_certified_canonical_head().await, SubscriberEvent::PreconfirmedLogs { flashblock, logs } => { let logs = self.filter_preconfirmed_logs(&flashblock, logs)?; Ok(Some(if logs.is_empty() { @@ -14663,54 +15825,220 @@ where } } - fn accept_base_flashblock( + async fn fetch_certified_canonical_head( &mut self, - payload: BaseFlashblockWirePayload, - ) -> Result<(FlashblockRef, bool), SubscriberError> { - let provider = self.provider_ref.clone().ok_or({ - SubscriberError::InvalidConfig( - "Flashblocks require a stable provider ref from a pinned provider lease", - ) - })?; - - let (flashblock, recover_pending_snapshot) = match payload { - BaseFlashblockWirePayload::Indexed(payload) => { - if payload.index == 0 { - let base = payload.base.clone().ok_or_else(|| { - SubscriberError::Provider( - "Base newFlashblocks index zero omitted its base header".into(), - ) - })?; - self.base_flashblock_header = Some((payload.payload_id, base)); - } - - let base = self - .base_flashblock_header - .as_ref() - .filter(|(payload_id, _)| *payload_id == payload.payload_id) - .map(|(_, base)| base); - let block_number = base.map(|base| base.block_number).or_else(|| { - payload - .metadata - .as_ref() - .map(|metadata| metadata.block_number) - }); - let block_number = block_number.ok_or_else(|| { - SubscriberError::Provider( - "Base newFlashblocks payload omitted both base and metadata block number" - .into(), - ) + ) -> Result>, SubscriberError> { + if self.chain_id.and_then(flashblocks_adapter) + == Some(FlashblocksAdapter::PendingStatePolling) + { + if !self.reserve_flashblock_rpc_methods(2) { + return Ok(None); + } + self.flashblocks_rpc_metrics.pending_block_requests = self + .flashblocks_rpc_metrics + .pending_block_requests + .saturating_add(1); + let pending = self + .fetch_op_pending_block() + .await + .map_err(PendingFlashblockPollError::into_subscriber)? + .ok_or_else(|| { + SubscriberError::Provider( + "provider returned no OP pending block while certifying its parent".into(), + ) + })?; + let header = self + .certify_op_pending_parent(&pending) + .await + .map_err(PendingFlashblockPollError::into_subscriber)?; + let certified = BlockRef { + number: header.number(), + hash: header.hash(), + parent_hash: Some(header.parent_hash()), + timestamp: Some(header.timestamp()), + }; + if self.last_certified_canonical_head.as_ref() == Some(&certified) { + return Ok(None); + } + self.last_certified_canonical_head = Some(certified); + return Ok(Some(SubscriberEvent::BlockHeader(header))); + } + self.flashblocks_rpc_metrics.canonical_head_requests = self + .flashblocks_rpc_metrics + .canonical_head_requests + .saturating_add(1); + let block = self + .provider + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .map_err(provider_error)? + .ok_or_else(|| { + SubscriberError::Provider( + "provider returned no latest block while certifying canonical head".into(), + ) + })?; + let header = block.header(); + if header.hash().is_zero() { + return Err(SubscriberError::Provider( + "provider returned a placeholder hash for the latest canonical head".into(), + )); + } + let certified = BlockRef { + number: header.number(), + hash: header.hash(), + parent_hash: Some(header.parent_hash()), + timestamp: Some(header.timestamp()), + }; + if self.last_certified_canonical_head.as_ref() == Some(&certified) { + return Ok(None); + } + self.last_certified_canonical_head = Some(certified); + Ok(Some(SubscriberEvent::BlockHeader(header.clone()))) + } + + fn accept_base_flashblock( + &mut self, + payload: BaseFlashblockWirePayload, + ) -> Result<(FlashblockRef, bool), SubscriberError> { + let provider = self.provider_ref.clone().ok_or({ + SubscriberError::InvalidConfig( + "Flashblocks require a stable provider ref from a pinned provider lease", + ) + })?; + + let (flashblock, recover_pending_snapshot) = match payload { + BaseFlashblockWirePayload::Indexed(payload) => { + if payload.index == 0 { + let base = payload.base.clone().ok_or_else(|| { + SubscriberError::Provider( + "indexed newFlashblocks item zero omitted its base header".into(), + ) + })?; + self.base_flashblock_header = Some((payload.payload_id, base)); + } + + let base = self + .base_flashblock_header + .as_ref() + .filter(|(payload_id, _)| *payload_id == payload.payload_id) + .map(|(_, base)| base); + let block_number = base.map(|base| base.block_number).or_else(|| { + payload + .metadata + .as_ref() + .map(|metadata| metadata.block_number) + }); + let block_number = block_number.ok_or_else(|| { + SubscriberError::Provider( + "indexed newFlashblocks payload omitted both base and metadata block number" + .into(), + ) })?; + let diff_transactions = flashblock_transaction_hashes(&payload.diff.transactions)?; + let transaction_hashes = match self.base_flashblock_transactions.as_mut() { + Some((known_payload, known_index, transactions, last_diff)) + if *known_payload == payload.payload_id => + { + if payload.index < *known_index { + return self + .latest_preconfirmation + .clone() + .map(|flashblock| (flashblock, false)) + .ok_or_else(|| { + SubscriberError::Provider( + "regressive indexed Flashblock arrived without an active snapshot" + .into(), + ) + }); + } + if payload.index == *known_index { + if *last_diff != diff_transactions { + return Err(SubscriberError::Provider( + "conflicting duplicate indexed Flashblock payload".into(), + )); + } + } else { + if diff_transactions + .iter() + .any(|hash| transactions.contains(hash)) + { + return Err(SubscriberError::Provider( + "indexed Flashblock repeated a transaction from an earlier diff" + .into(), + )); + } + transactions.extend(diff_transactions.iter().copied()); + *known_index = payload.index; + *last_diff = diff_transactions; + } + transactions.clone() + } + _ => { + self.base_flashblock_transactions = Some(( + payload.payload_id, + payload.index, + diff_transactions.clone(), + diff_transactions.clone(), + )); + diff_transactions + } + }; + let partial_block_hash = non_placeholder_hash(payload.diff.block_hash); + let transactions_root = payload + .diff + .transactions_root + .and_then(non_placeholder_hash); + let parent_hash = base.and_then(|base| non_placeholder_hash(base.parent_hash)); + let state_root = non_placeholder_hash(payload.diff.state_root); + let timestamp = base.map(|base| base.timestamp); + let base_fee_per_gas = base.and_then(|base| base.base_fee_per_gas); + let beneficiary = base.and_then(|base| base.beneficiary); + let prevrandao = base + .and_then(|base| base.prevrandao) + .and_then(non_placeholder_hash); + let gas_limit = base.and_then(|base| base.gas_limit); + let content_hash = flashblock_content_hash(FlashblockContentCommitment { + provider: &provider, + payload_id: Some(payload.payload_id), + index: Some(payload.index), + block_number, + partial_block_hash, + parent_hash, + state_root, + transactions_root, + transaction_hashes: &transaction_hashes, + timestamp, + base_fee_per_gas, + beneficiary, + prevrandao, + gas_limit, + }); let flashblock = FlashblockRef { provider, payload_id: Some(payload.payload_id), index: Some(payload.index), block_number, - block_hash: payload.diff.block_hash, - parent_hash: base.map(|base| base.parent_hash), - state_root: Some(payload.diff.state_root), - timestamp: base.map(|base| base.timestamp), + content_hash, + partial_block_hash, + parent_hash, + state_root, + transactions_root, + transaction_hashes, + timestamp, + base_fee_per_gas, + beneficiary, + prevrandao, + gas_limit, }; + if let Some(previous) = self.latest_preconfirmation.as_ref() + && previous.same_payload(&flashblock) + && previous.index == flashblock.index + && previous.content_hash != flashblock.content_hash + { + return Err(SubscriberError::Provider( + "conflicting duplicate indexed Flashblock content".into(), + )); + } let recover = match self.latest_preconfirmation.as_ref() { Some(previous) if previous.same_payload(&flashblock) => { if let (Some(previous), Some(current)) = (previous.index, flashblock.index) @@ -14729,107 +16057,296 @@ where (flashblock, recover) } BaseFlashblockWirePayload::Block(payload) => { - let index = self - .latest_preconfirmation - .as_ref() - .filter(|previous| { - previous.block_number == payload.number - && previous.parent_hash == Some(payload.parent_hash) - }) - .and_then(|previous| previous.index) - .map_or(0, |index| index.saturating_add(1)); - ( - FlashblockRef { - provider, - payload_id: None, - index: Some(index), - block_number: payload.number, - block_hash: payload.hash, - parent_hash: Some(payload.parent_hash), - state_root: Some(payload.state_root), - timestamp: Some(payload.timestamp), - }, - false, - ) + let transaction_hashes = flashblock_transaction_hashes(&payload.transactions)?; + let parent_hash = non_placeholder_hash(payload.parent_hash); + let state_root = non_placeholder_hash(payload.state_root); + let transactions_root = payload.transactions_root.and_then(non_placeholder_hash); + let partial_block_hash = non_placeholder_hash(payload.hash); + let prevrandao = payload.mix_hash.and_then(non_placeholder_hash); + let content_hash = flashblock_content_hash(FlashblockContentCommitment { + provider: &provider, + payload_id: None, + index: None, + block_number: payload.number, + partial_block_hash, + parent_hash, + state_root, + transactions_root, + transaction_hashes: &transaction_hashes, + timestamp: Some(payload.timestamp), + base_fee_per_gas: payload.base_fee_per_gas, + beneficiary: payload.miner, + prevrandao, + gas_limit: payload.gas_limit, + }); + let flashblock = FlashblockRef { + provider, + payload_id: None, + index: None, + block_number: payload.number, + content_hash, + partial_block_hash, + parent_hash, + state_root, + transactions_root, + transaction_hashes, + timestamp: Some(payload.timestamp), + base_fee_per_gas: payload.base_fee_per_gas, + beneficiary: payload.miner, + prevrandao, + gas_limit: payload.gas_limit, + }; + if let Some(previous) = self.latest_preconfirmation.as_ref() + && flashblock.same_payload(previous) + && flashblock.content_hash != previous.content_hash + && !flashblock.is_cumulative_successor_of(previous) + { + return Err(SubscriberError::Provider( + "cumulative Flashblock transaction membership is non-monotonic".into(), + )); + } + (flashblock, false) } }; + Ok((flashblock, recover_pending_snapshot)) + } - self.flashblocks_by_hash - .insert(flashblock.block_hash, flashblock.clone()); - self.flashblock_hash_order.push_back(flashblock.block_hash); - while self.flashblock_hash_order.len() > 64 { - if let Some(hash) = self.flashblock_hash_order.pop_front() { - self.flashblocks_by_hash.remove(&hash); + async fn poll_op_pending_flashblock( + &mut self, + ) -> Result>, SubscriberError> { + match self.fetch_pending_flashblock(None).await { + Ok(event) => { + self.consecutive_flashblock_poll_failures = 0; + Ok(event) + } + Err(PendingFlashblockPollError::Request(error)) => { + self.flashblocks_rpc_metrics.failed_requests = self + .flashblocks_rpc_metrics + .failed_requests + .saturating_add(1); + self.consecutive_flashblock_poll_failures = + self.consecutive_flashblock_poll_failures.saturating_add(1); + if self.consecutive_flashblock_poll_failures + >= self.config.max_consecutive_flashblock_poll_failures + { + return Err(error); + } + tracing::warn!( + consecutive_failures = self.consecutive_flashblock_poll_failures, + failure_limit = self.config.max_consecutive_flashblock_poll_failures, + error = %error, + "Optimism pending-state Flashblocks request failed; retrying on the next tick" + ); + Ok(None) } + Err(PendingFlashblockPollError::Integrity(error)) => Err(error), } - Ok((flashblock, recover_pending_snapshot)) } async fn fetch_pending_flashblock( &mut self, - ) -> Result>, SubscriberError> { - let latest = self - .provider - .get_block_number() - .await - .map_err(provider_error)?; - let Some(block) = self - .provider - .get_block_by_number(BlockNumberOrTag::Pending) - .await - .map_err(provider_error)? - else { + indexed_recovery: Option<(FixedBytes<8>, u64, Vec)>, + ) -> Result>, PendingFlashblockPollError> { + let samples_pending_range = self.chain_id.and_then(flashblocks_adapter) + == Some(FlashblocksAdapter::PendingStatePolling); + if samples_pending_range { + let fixed_methods = 2_usize.saturating_add(self.log_stream_filters().len()); + if !self.reserve_flashblock_rpc_methods(fixed_methods) { + return Ok(None); + } + } + let state_provider = if samples_pending_range { + self.flashblocks_state_provider + .as_ref() + .unwrap_or(&self.provider) + } else { + &self.provider + }; + let latest = if samples_pending_range { + None + } else { + self.flashblocks_rpc_metrics.canonical_head_requests = self + .flashblocks_rpc_metrics + .canonical_head_requests + .saturating_add(1); + Some( + state_provider + .get_block_number() + .await + .map_err(pending_flashblock_request_error)?, + ) + }; + self.flashblocks_rpc_metrics.pending_block_requests = self + .flashblocks_rpc_metrics + .pending_block_requests + .saturating_add(1); + let pending_block = if samples_pending_range { + self.fetch_op_pending_block().await? + } else { + self.provider + .get_block_by_number(BlockNumberOrTag::Pending) + .await + .map_err(pending_flashblock_request_error)? + }; + let Some(block) = pending_block else { if self.config.preconfirmations == PreconfirmationMode::Required { - return Err(SubscriberError::Provider( - "Flashblocks provider returned no pending block".into(), + return Err(PendingFlashblockPollError::Request( + SubscriberError::Provider( + "Flashblocks provider returned no pending block".into(), + ), )); } return Ok(None); }; + let latest = if samples_pending_range { + self.certify_op_pending_parent(&block).await?.number() + } else { + latest.expect("non-OP pending recovery fetched a canonical height") + }; let header = block.header(); if header.number() <= latest { - if self.config.preconfirmations == PreconfirmationMode::Required { - return Err(SubscriberError::Provider( - "Flashblocks provider pending state did not advance beyond the canonical head" - .into(), - )); - } return Ok(None); } let provider = self.provider_ref.clone().ok_or({ - SubscriberError::InvalidConfig( + PendingFlashblockPollError::Integrity(SubscriberError::InvalidConfig( "Flashblocks require a stable provider ref from a pinned provider lease", - ) + )) })?; let parent_hash = Some(header.parent_hash()); - let index = self.latest_preconfirmation.as_ref().and_then(|previous| { - (previous.block_number == header.number() && previous.parent_hash == parent_hash) - .then(|| previous.index.unwrap_or(0).saturating_add(1)) + let transaction_hashes = if let Some(hashes) = block.transactions().as_hashes() { + hashes.to_vec() + } else if let Some(transactions) = block.transactions().as_transactions() { + transactions + .iter() + .map(|transaction| transaction.tx_hash()) + .collect() + } else { + Vec::new() + }; + let state_root = non_placeholder_hash(header.state_root()); + let transactions_root = non_placeholder_hash(header.transactions_root()); + let partial_block_hash = non_placeholder_hash(header.hash()); + let prevrandao = header.mix_hash().and_then(non_placeholder_hash); + let content_hash = flashblock_content_hash(FlashblockContentCommitment { + provider: &provider, + payload_id: None, + index: None, + block_number: header.number(), + partial_block_hash, + parent_hash, + state_root, + transactions_root, + transaction_hashes: &transaction_hashes, + timestamp: Some(header.timestamp()), + base_fee_per_gas: header.base_fee_per_gas(), + beneficiary: Some(header.beneficiary()), + prevrandao, + gas_limit: Some(header.gas_limit()), }); let flashblock = FlashblockRef { provider, payload_id: None, - index: Some(index.unwrap_or(0)), + index: None, block_number: header.number(), - block_hash: header.hash(), + content_hash, + partial_block_hash, parent_hash, - state_root: Some(header.state_root()), + state_root, + transactions_root, + transaction_hashes, timestamp: Some(header.timestamp()), + base_fee_per_gas: header.base_fee_per_gas(), + beneficiary: Some(header.beneficiary()), + prevrandao, + gas_limit: Some(header.gas_limit()), }; - if self + if samples_pending_range + && self + .latest_preconfirmation + .as_ref() + .is_some_and(|previous| !previous.same_payload(&flashblock)) + { + // Revoke as soon as the sampled payload changes, before any + // follow-up receipt await can fail or be cancelled. + self.invalidate_preconfirmation_snapshot(); + } + if let Some((payload_id, index, last_diff)) = indexed_recovery { + self.base_flashblock_transactions = Some(( + payload_id, + index, + flashblock.transaction_hashes.clone(), + last_diff, + )); + } + let repeats_pending_snapshot = self .latest_preconfirmation .as_ref() - .is_some_and(|previous| { - previous.block_hash == flashblock.block_hash - && previous.block_number == flashblock.block_number - }) - { + .is_some_and(|previous| previous == &flashblock); + if repeats_pending_snapshot && !samples_pending_range { return Ok(None); } - let logs = self.fetch_pending_logs().await?; - let logs = self.filter_preconfirmed_logs(&flashblock, logs)?; + if let Some(previous) = self.latest_preconfirmation.as_ref() + && flashblock.same_payload(previous) + && !flashblock.is_cumulative_successor_of(previous) + { + if samples_pending_range { + // OP pending-state reads are not atomic and paid endpoints can + // briefly expose a shorter backend view. Never publish the + // regression. Revoke the active overlay and require a fresh, + // internally coherent sample on a later tick instead. + self.invalidate_preconfirmation_snapshot(); + return Ok(Some(SubscriberEvent::FlashblockInvalidated)); + } + return Err(PendingFlashblockPollError::Integrity( + SubscriberError::Provider( + "sampled cumulative Flashblock transaction membership is non-monotonic".into(), + ), + )); + } + + let mut logs = self.fetch_pending_logs(flashblock.block_number).await?; + if samples_pending_range { + let (mut receipt_logs, completed_receipts, unavailable_receipts) = + self.fetch_pending_transaction_receipts(&flashblock).await?; + logs.append(&mut receipt_logs); + logs.retain(|log| log.block_number == Some(flashblock.block_number)); + for log in &logs { + let transaction_hash = log.transaction_hash.ok_or_else(|| { + PendingFlashblockPollError::Integrity(SubscriberError::Provider( + "pre-confirmed log is missing its transaction hash".into(), + )) + })?; + if !flashblock.contains_transaction(&transaction_hash) { + self.flashblocks_rpc_metrics.raced_samples = + self.flashblocks_rpc_metrics.raced_samples.saturating_add(1); + return Ok(None); + } + } + let logs = self + .filter_preconfirmed_logs(&flashblock, logs) + .map_err(PendingFlashblockPollError::Integrity)?; + self.preconfirmed_unavailable_receipts + .extend(unavailable_receipts); + for transaction_hash in &completed_receipts { + self.preconfirmed_unavailable_receipts + .remove(transaction_hash); + } + self.preconfirmed_receipted_transactions + .extend(completed_receipts); + if repeats_pending_snapshot && logs.is_empty() { + return Ok(None); + } + return Ok(Some(if logs.is_empty() { + SubscriberEvent::FlashblockObserved + } else { + SubscriberEvent::PreconfirmedLogs { flashblock, logs } + })); + } + let logs = self + .filter_preconfirmed_logs(&flashblock, logs) + .map_err(PendingFlashblockPollError::Integrity)?; Ok(Some(if logs.is_empty() { SubscriberEvent::FlashblockObserved } else { @@ -14837,27 +16354,226 @@ where })) } - async fn fetch_pending_logs(&mut self) -> Result, SubscriberError> { + async fn fetch_pending_logs( + &mut self, + pending_block_number: u64, + ) -> Result, PendingFlashblockPollError> { let mut logs = Vec::new(); + let samples_pending_range = self.chain_id.and_then(flashblocks_adapter) + == Some(FlashblocksAdapter::PendingStatePolling); + let state_provider = if samples_pending_range { + self.flashblocks_state_provider + .as_ref() + .unwrap_or(&self.provider) + } else { + &self.provider + }; for filter in self.log_stream_filters() { - let filter = filter - .from_block(BlockNumberOrTag::Pending) - .to_block(BlockNumberOrTag::Pending); + self.flashblocks_rpc_metrics.pending_log_requests = self + .flashblocks_rpc_metrics + .pending_log_requests + .saturating_add(1); + let filter = if samples_pending_range { + filter + .from_block(pending_block_number) + .to_block(BlockNumberOrTag::Pending) + } else { + filter + .from_block(BlockNumberOrTag::Pending) + .to_block(BlockNumberOrTag::Pending) + }; logs.extend( - self.provider + state_provider .get_logs(&filter) .await - .map_err(provider_error)?, + .map_err(pending_flashblock_request_error)?, ); } + if samples_pending_range { + logs.retain(|log| log.block_number == Some(pending_block_number)); + } Ok(logs) } + async fn fetch_pending_transaction_receipts( + &mut self, + flashblock: &FlashblockRef, + ) -> Result<(Vec, Vec, Vec), PendingFlashblockPollError> { + let receipt_allowance = self.pending_receipt_request_allowance(); + let receipt_limit = self + .config + .max_pending_transaction_receipts_per_tick + .min(receipt_allowance); + if receipt_limit == 0 { + return Ok((Vec::new(), Vec::new(), Vec::new())); + } + let mut transaction_hashes = Vec::with_capacity(receipt_limit); + for transaction_hash in &flashblock.transaction_hashes { + if !self + .preconfirmed_receipted_transactions + .contains(transaction_hash) + && !self + .preconfirmed_unavailable_receipts + .contains(transaction_hash) + { + transaction_hashes.push(*transaction_hash); + if transaction_hashes.len() == receipt_limit { + break; + } + } + } + if transaction_hashes.len() < receipt_limit { + for transaction_hash in &flashblock.transaction_hashes { + if self + .preconfirmed_unavailable_receipts + .contains(transaction_hash) + { + transaction_hashes.push(*transaction_hash); + if transaction_hashes.len() == receipt_limit { + break; + } + } + } + } + if transaction_hashes.is_empty() { + return Ok((Vec::new(), Vec::new(), Vec::new())); + } + let reserved = self.reserve_flashblock_rpc_methods(transaction_hashes.len()); + debug_assert!(reserved, "receipt allowance must remain reserved until use"); + if !reserved { + return Ok((Vec::new(), Vec::new(), Vec::new())); + } + self.flashblocks_rpc_metrics.pending_receipt_requests = self + .flashblocks_rpc_metrics + .pending_receipt_requests + .saturating_add(transaction_hashes.len() as u64); + let state_provider = self + .flashblocks_state_provider + .as_ref() + .unwrap_or(&self.provider); + let client = state_provider.client(); + let mut batch = BatchRequest::new(client); + let mut waiters = Vec::with_capacity(transaction_hashes.len()); + for transaction_hash in transaction_hashes { + let waiter = batch + .add_call::<_, serde_json::Value>("eth_getTransactionReceipt", &(transaction_hash,)) + .map_err(pending_flashblock_request_error)?; + waiters.push((transaction_hash, waiter)); + } + batch + .send() + .await + .map_err(pending_flashblock_request_error)?; + let mut logs = Vec::new(); + let mut completed = Vec::new(); + let mut unavailable = Vec::new(); + for (transaction_hash, waiter) in waiters { + let value = waiter.await.map_err(pending_flashblock_request_error)?; + if let Some(mut receipt_logs) = + normalize_pending_transaction_receipt(transaction_hash, value) + .map_err(PendingFlashblockPollError::Integrity)? + { + self.flashblocks_rpc_metrics.pending_receipts_completed = self + .flashblocks_rpc_metrics + .pending_receipts_completed + .saturating_add(1); + logs.append(&mut receipt_logs); + completed.push(transaction_hash); + } else { + self.flashblocks_rpc_metrics.pending_receipts_unavailable = self + .flashblocks_rpc_metrics + .pending_receipts_unavailable + .saturating_add(1); + unavailable.push(transaction_hash); + } + } + Ok((logs, completed, unavailable)) + } + + fn pending_receipt_request_allowance(&mut self) -> usize { + self.prune_flashblock_rpc_request_times(); + let rolling_capacity = self + .config + .max_flashblock_rpc_requests_per_second + .saturating_sub(self.flashblock_rpc_request_times.len()); + rolling_capacity.min(self.pending_receipt_requests_per_tick_capacity()) + } + + fn pending_receipt_requests_per_tick_capacity(&self) -> usize { + let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1); + let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos); + let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX); + self.pending_receipt_requests_per_second_capacity() + .checked_div(ticks_per_second) + .unwrap_or(0) + } + + fn pending_receipt_requests_per_second_capacity(&self) -> usize { + let interval_nanos = self.config.flashblock_poll_interval.as_nanos().max(1); + let ticks_per_second = Duration::from_secs(1).as_nanos().div_ceil(interval_nanos); + let ticks_per_second = usize::try_from(ticks_per_second).unwrap_or(usize::MAX); + let fixed_methods_per_tick = 2_usize.saturating_add(self.log_stream_filters().len()); + let mut reserved_methods = ticks_per_second.saturating_mul(fixed_methods_per_tick); + if needs_header_block_stream(&self.interests) { + let canonical_interval_nanos = + self.config.canonical_head_poll_interval.as_nanos().max(1); + let canonical_ticks = Duration::from_secs(1) + .as_nanos() + .div_ceil(canonical_interval_nanos); + let canonical_ticks = usize::try_from(canonical_ticks).unwrap_or(usize::MAX); + reserved_methods = reserved_methods.saturating_add(canonical_ticks.saturating_mul(2)); + } + self.config + .max_flashblock_rpc_requests_per_second + .saturating_sub(reserved_methods) + } + + fn reserve_flashblock_rpc_methods(&mut self, methods: usize) -> bool { + self.prune_flashblock_rpc_request_times(); + if self + .flashblock_rpc_request_times + .len() + .saturating_add(methods) + > self.config.max_flashblock_rpc_requests_per_second + { + return false; + } + let now = Instant::now(); + for _ in 0..methods { + self.flashblock_rpc_request_times.push_back(now); + } + true + } + + fn prune_flashblock_rpc_request_times(&mut self) { + let now = Instant::now(); + while self + .flashblock_rpc_request_times + .front() + .is_some_and(|requested| now.duration_since(*requested) >= Duration::from_secs(1)) + { + self.flashblock_rpc_request_times.pop_front(); + } + } + fn filter_preconfirmed_logs( &mut self, flashblock: &FlashblockRef, mut logs: Vec, ) -> Result, SubscriberError> { + let samples_pending_range = self.chain_id.and_then(flashblocks_adapter) + == Some(FlashblocksAdapter::PendingStatePolling); + if self + .latest_preconfirmation + .as_ref() + .is_some_and(|previous| !previous.same_payload(flashblock)) + { + // A new payload revokes the previous overlay even when none of the + // caller's log filters matched in the replacement. Otherwise a + // quiet block could leave stale speculative signing authority + // active until an unrelated canonical pool event arrived. + self.invalidate_preconfirmation_snapshot(); + } if self .latest_preconfirmation .as_ref() @@ -14876,11 +16592,8 @@ where logs.sort_by_key(|log| (log.transaction_index.unwrap_or(u64::MAX), log.log_index)); let mut filtered = Vec::new(); - for log in logs { - if log.removed - || log.block_number != Some(flashblock.block_number) - || log.block_hash != Some(flashblock.block_hash) - { + for mut log in logs { + if log.removed || log.block_number != Some(flashblock.block_number) { return Err(SubscriberError::Provider( "pre-confirmed log disagrees with its Flashblock snapshot".into(), )); @@ -14893,13 +16606,45 @@ where let log_index = log.log_index.ok_or_else(|| { SubscriberError::Provider("pre-confirmed log is missing its log index".into()) })?; - if self - .preconfirmed_seen_logs - .insert((transaction_hash, log_index)) - && log_matches_any_interest(&log, &self.interests) + let transaction_index = + flashblock + .transaction_index(&transaction_hash) + .ok_or_else(|| { + SubscriberError::Provider( + "pre-confirmed log transaction is absent from the cumulative Flashblock" + .into(), + ) + })?; + if log + .transaction_index + .is_some_and(|reported| reported != transaction_index) { - filtered.push(log); - } + return Err(SubscriberError::Provider( + "pre-confirmed log transaction index disagrees with cumulative membership" + .into(), + )); + } + let reported_hash = log.block_hash.and_then(non_placeholder_hash); + if !samples_pending_range + && let (Some(reported), Some(expected)) = + (reported_hash, flashblock.partial_block_hash) + && reported != expected + { + return Err(SubscriberError::Provider( + "pre-confirmed log partial block hash disagrees with its Flashblock snapshot" + .into(), + )); + } + log.block_hash = Some(flashblock.content_hash); + log.block_timestamp = flashblock.timestamp.or(log.block_timestamp); + log.transaction_index = Some(transaction_index); + if self + .preconfirmed_seen_logs + .insert((transaction_hash, log_index)) + && log_matches_any_interest(&log, &self.interests) + { + filtered.push(log); + } } Ok(filtered) } @@ -14925,7 +16670,9 @@ where | SubscriberEvent::BasePendingLog { .. } | SubscriberEvent::BaseFlashblock(_) | SubscriberEvent::OpFlashblockTick + | SubscriberEvent::CanonicalHeadTick | SubscriberEvent::PreconfirmedLogs { .. } + | SubscriberEvent::FlashblockInvalidated | SubscriberEvent::FlashblockObserved | SubscriberEvent::StreamTerminated(_) => Ok(()), } @@ -15019,7 +16766,9 @@ where | SubscriberEvent::BasePendingLog { .. } | SubscriberEvent::BaseFlashblock(_) | SubscriberEvent::OpFlashblockTick + | SubscriberEvent::CanonicalHeadTick | SubscriberEvent::PreconfirmedLogs { .. } + | SubscriberEvent::FlashblockInvalidated | SubscriberEvent::FlashblockObserved | SubscriberEvent::StreamTerminated(_) => {} } @@ -15155,9 +16904,13 @@ where }); } } + SubscriberEvent::FlashblockInvalidated => { + self.pending_preconfirmation_invalidation = true; + } SubscriberEvent::BasePendingLog { .. } | SubscriberEvent::BaseFlashblock(_) | SubscriberEvent::OpFlashblockTick + | SubscriberEvent::CanonicalHeadTick | SubscriberEvent::FlashblockObserved => {} SubscriberEvent::StreamTerminated(_) => {} } @@ -15327,7 +17080,7 @@ where source: &SubscriberStreamSource, ) -> Result>, SubscriberError> { if source.is_flashblocks() { - return self.fetch_pending_flashblock().await; + return Ok(None); } let SubscriberStreamSource::PubSubLog { id, filter } = source else { return Ok(None); @@ -15719,6 +17472,157 @@ where .boxed() } +fn flashblock_reconnect_future( + provider: RootProvider, + source: SubscriberStreamSource, + channel_size: usize, + reconnect: SubscriberReconnectConfig, + first_delay: Duration, + flashblock_poll_interval: Duration, +) -> FlashblockReconnectFuture +where + N: Network + 'static, +{ + Box::pin(async move { + if !reconnect.enabled { + let error = SubscriberError::Provider(format!( + "Alloy subscriber {} stream terminated and reconnect is disabled", + source.label() + )); + return (source, Err(error)); + } + + let mut attempts = 0_usize; + let mut delay = first_delay; + let mut retry_delay = reconnect.retry_delay; + loop { + attempts = attempts.saturating_add(1); + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + match connect_flashblock_source_once( + &provider, + source.clone(), + channel_size, + flashblock_poll_interval, + ) + .await + { + Ok(stream) => return (source, Ok(stream)), + Err(error) if reconnect_attempts_exhausted(attempts, &reconnect) => { + return ( + source.clone(), + Err(SubscriberError::Provider(format!( + "Alloy subscriber {} stream reconnect failed after {attempts} attempt(s): {error}", + source.label() + ))), + ); + } + Err(error) => { + tracing::warn!( + stream = source.label(), + attempts, + error = %error, + "Flashblocks reconnect attempt failed" + ); + delay = retry_delay; + retry_delay = next_reconnect_delay(retry_delay, reconnect.max_delay); + } + } + } + }) +} + +async fn connect_flashblock_source_once( + provider: &RootProvider, + source: SubscriberStreamSource, + channel_size: usize, + flashblock_poll_interval: Duration, +) -> Result>, SubscriberError> +where + N: Network + 'static, +{ + #[cfg(not(feature = "reactive-ws"))] + let _ = provider; + + match source { + SubscriberStreamSource::BasePendingLog { id, filter } => { + #[cfg(feature = "reactive-ws")] + { + let source = SubscriberStreamSource::BasePendingLog { + id, + filter: filter.clone(), + }; + let params = base_pending_log_filter(&filter)?; + let stream = provider + .subscribe::<_, Log>(("pendingLogs", params)) + .channel_size(channel_size.max(1)) + .await + .map_err(provider_error)? + .into_stream() + .map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log }); + Ok(stream_with_termination(stream, source)) + } + #[cfg(not(feature = "reactive-ws"))] + { + let _ = (id, filter, channel_size); + Err(SubscriberError::Unsupported( + "Base Flashblocks require the reactive-ws feature", + )) + } + } + SubscriberStreamSource::BaseFlashblocks => { + #[cfg(feature = "reactive-ws")] + { + let stream = provider + .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",)) + .channel_size(channel_size.max(1)) + .await + .map_err(provider_error)? + .into_stream() + .map(SubscriberEvent::BaseFlashblock); + Ok(stream_with_termination( + stream, + SubscriberStreamSource::BaseFlashblocks, + )) + } + #[cfg(not(feature = "reactive-ws"))] + { + let _ = channel_size; + Err(SubscriberError::Unsupported( + "Base Flashblocks require the reactive-ws feature", + )) + } + } + SubscriberStreamSource::OpPendingFlashblocks => { + let first_tick = tokio::time::Instant::now(); + let mut interval = tokio::time::interval_at(first_tick, flashblock_poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let stream = stream::unfold(interval, |mut interval| async move { + interval.tick().await; + Some((SubscriberEvent::OpFlashblockTick, interval)) + }); + Ok(stream_with_termination( + stream, + SubscriberStreamSource::OpPendingFlashblocks, + )) + } + source => Err(SubscriberError::InvalidConfig(match source { + SubscriberStreamSource::PubSubLog { .. } + | SubscriberStreamSource::CanonicalHeadPolling + | SubscriberStreamSource::PubSubPendingHashes + | SubscriberStreamSource::PubSubBlockHeaders + | SubscriberStreamSource::PollingLog { .. } + | SubscriberStreamSource::PollingPendingHashes => { + "Flashblocks reconnect received a canonical source" + } + SubscriberStreamSource::BasePendingLog { .. } + | SubscriberStreamSource::BaseFlashblocks + | SubscriberStreamSource::OpPendingFlashblocks => unreachable!(), + })), + } +} + fn aggregate_interests( base: &[ReactiveInterest], owned: &[OwnedSubscriberInterests], @@ -15769,27 +17673,1538 @@ mod subscriber_helper_tests { use alloy_provider::ProviderBuilder; use alloy_transport::mock::Asserter; - #[test] - fn base_flashblock_wire_decodes_cumulative_block_shape() { - let payload: BaseFlashblockWirePayload = serde_json::from_str( - r#"{ - "hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "number":"0x2ef403b", - "parentHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "stateRoot":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - "timestamp":"0x6a68dd59", - "transactions":[] - }"#, + fn indexed_flashblock(transaction_hash: B256, state_root: B256) -> BaseFlashblockWirePayload { + BaseFlashblockWirePayload::Indexed(BaseFlashblockPayload { + payload_id: FixedBytes::repeat_byte(0x11), + index: 0, + base: Some(BaseFlashblockBase { + parent_hash: B256::repeat_byte(100), + block_number: 101, + timestamp: 1_700_000_101, + gas_limit: Some(30_000_000), + base_fee_per_gas: Some(7), + beneficiary: Some(Address::repeat_byte(0xcb)), + prevrandao: Some(B256::repeat_byte(0x77)), + }), + diff: BaseFlashblockDiff { + state_root, + block_hash: B256::ZERO, + transactions: vec![serde_json::Value::String(format!("{transaction_hash:#x}"))], + transactions_root: None, + }, + metadata: None, + }) + } + + #[test] + fn duplicate_flashblock_transaction_membership_is_rejected() { + let transaction = format!("{:#x}", B256::repeat_byte(0x41)); + let transactions = vec![ + serde_json::Value::String(transaction.clone()), + serde_json::Value::String(transaction), + ]; + assert!(matches!( + flashblock_transaction_hashes(&transactions), + Err(SubscriberError::Provider(ref message)) if message.contains("duplicate") + )); + } + + #[test] + fn conflicting_duplicate_indexed_flashblock_is_rejected() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ) + .with_provider_ref(ProviderRef::new("base-paid", 7)); + subscriber.chain_id = Some(8_453); + + subscriber + .accept_base_flashblock(indexed_flashblock( + B256::repeat_byte(0x41), + B256::repeat_byte(0xa1), + )) + .expect("first indexed preview"); + assert!(matches!( + subscriber.accept_base_flashblock(indexed_flashblock( + B256::repeat_byte(0x42), + B256::repeat_byte(0xa2), + )), + Err(SubscriberError::Provider(ref message)) + if message.contains("conflicting duplicate") + )); + } + + #[tokio::test] + async fn duplicate_index_with_changed_commitment_is_rejected() { + let transaction = B256::repeat_byte(0x41); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ) + .with_provider_ref(ProviderRef::new("base-paid", 7)); + subscriber.chain_id = Some(8_453); + + subscriber + .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock( + transaction, + B256::repeat_byte(0xa1), + ))) + .await + .expect("first indexed preview"); + + let BaseFlashblockWirePayload::Indexed(mut conflicting) = + indexed_flashblock(transaction, B256::repeat_byte(0xa1)) + else { + unreachable!() + }; + conflicting.diff.state_root = B256::repeat_byte(0xbb); + assert!(matches!( + subscriber + .normalize_flashblock_event(SubscriberEvent::BaseFlashblock( + BaseFlashblockWirePayload::Indexed(conflicting), + )) + .await, + Err(SubscriberError::Provider(ref message)) + if message.contains("conflicting duplicate indexed Flashblock content") + )); + } + + #[tokio::test] + async fn indexed_gap_recovery_seeds_later_cumulative_membership() { + let transaction_a = B256::repeat_byte(0x41); + let transaction_b = B256::repeat_byte(0x42); + let transaction_c = B256::repeat_byte(0x43); + let transaction_d = B256::repeat_byte(0x44); + let asserter = Asserter::new(); + asserter.push_success(&100_u64); + let pending = rpc_block(101, B256::ZERO).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![ + transaction_a, + transaction_b, + transaction_c, + ]), + ); + asserter.push_success(&Some(pending)); + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ) + .with_provider_ref(ProviderRef::new("base-paid", 7)); + subscriber.chain_id = Some(8_453); + + subscriber + .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock( + transaction_a, + B256::repeat_byte(0xa1), + ))) + .await + .expect("index zero preview"); + let BaseFlashblockWirePayload::Indexed(mut gap) = + indexed_flashblock(transaction_c, B256::repeat_byte(0xa3)) + else { + unreachable!() + }; + gap.index = 2; + gap.base = None; + gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 }); + subscriber + .normalize_flashblock_event(SubscriberEvent::BaseFlashblock( + BaseFlashblockWirePayload::Indexed(gap), + )) + .await + .expect("the missing index is recovered from pending state"); + + let BaseFlashblockWirePayload::Indexed(mut next) = + indexed_flashblock(transaction_d, B256::repeat_byte(0xa4)) + else { + unreachable!() + }; + next.index = 3; + next.base = None; + next.metadata = Some(BaseFlashblockMetadata { block_number: 101 }); + let (next, recover) = subscriber + .accept_base_flashblock(BaseFlashblockWirePayload::Indexed(next)) + .expect("the next diff extends the recovered cumulative set"); + assert!(!recover); + assert_eq!( + next.transaction_hashes, + vec![transaction_a, transaction_b, transaction_c, transaction_d] + ); + } + + #[tokio::test] + async fn unrecoverable_indexed_gap_revokes_the_generation() { + let asserter = Asserter::new(); + asserter.push_success(&100_u64); + asserter.push_success(&Some(rpc_block(100, B256::repeat_byte(0x64)))); + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("base-paid", 7)); + subscriber.chain_id = Some(8_453); + + subscriber + .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock( + B256::repeat_byte(0x41), + B256::repeat_byte(0xa1), + ))) + .await + .expect("index zero preview"); + let BaseFlashblockWirePayload::Indexed(mut gap) = + indexed_flashblock(B256::repeat_byte(0x43), B256::repeat_byte(0xa3)) + else { + unreachable!() + }; + gap.index = 2; + gap.base = None; + gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 }); + let event = subscriber + .normalize_flashblock_event(SubscriberEvent::BaseFlashblock( + BaseFlashblockWirePayload::Indexed(gap), + )) + .await + .expect("preferred mode fails closed without pending recovery") + .expect("generation invalidation is observable"); + assert!(matches!(event, SubscriberEvent::FlashblockInvalidated)); + assert!(subscriber.latest_preconfirmation.is_none()); + assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8); + } + + #[test] + fn base_flashblock_wire_decodes_cumulative_block_shape() { + let payload: BaseFlashblockWirePayload = serde_json::from_str( + r#"{ + "hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "number":"0x2ef403b", + "parentHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "stateRoot":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "timestamp":"0x6a68dd59", + "transactions":[] + }"#, + ) + .expect("decode current Base newFlashblocks shape"); + let BaseFlashblockWirePayload::Block(payload) = payload else { + panic!("expected cumulative block-shaped payload") + }; + assert_eq!(payload.number, 49_233_979); + assert_eq!(payload.timestamp, 1_785_257_305); + assert_eq!(payload.hash, B256::repeat_byte(0xaa)); + assert_eq!(payload.parent_hash, B256::repeat_byte(0xbb)); + assert_eq!(payload.state_root, B256::repeat_byte(0xcc)); + } + + #[tokio::test] + async fn zero_hash_pending_log_waits_for_the_preview_containing_its_transaction() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ) + .with_provider_ref(ProviderRef::new("base-paid", 7)); + subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(Address::repeat_byte(0x42)), + local_matcher: None, + route_key: None, + })]; + subscriber.interests = subscriber.base_interests.clone(); + + let first: BaseFlashblockWirePayload = serde_json::from_str( + r#"{ + "hash":"0x0000000000000000000000000000000000000000000000000000000000000000", + "number":"0x65", + "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464", + "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111", + "timestamp":"0x6553f165", + "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"] + }"#, + ) + .expect("decode first cumulative preview"); + subscriber + .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(first)) + .await + .expect("first preview is accepted"); + + let mut second_log = rpc_log(false); + second_log.block_hash = Some(B256::ZERO); + second_log.block_number = Some(102); + second_log.block_timestamp = Some(1_700_000_102); + second_log.transaction_hash = Some(B256::repeat_byte(0x42)); + second_log.transaction_index = Some(0); + second_log.log_index = Some(0); + + let before_preview = subscriber + .normalize_flashblock_event(SubscriberEvent::BasePendingLog { + source_id: 0, + log: second_log, + }) + .await + .expect("a zero-hash log for the next block must be buffered"); + assert!(before_preview.is_none()); + + let second: BaseFlashblockWirePayload = serde_json::from_str( + r#"{ + "hash":"0x0000000000000000000000000000000000000000000000000000000000000000", + "number":"0x66", + "parentHash":"0x6565656565656565656565656565656565656565656565656565656565656565", + "stateRoot":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "transactionsRoot":"0x2222222222222222222222222222222222222222222222222222222222222222", + "timestamp":"0x6553f166", + "transactions":["0x4242424242424242424242424242424242424242424242424242424242424242"] + }"#, + ) + .expect("decode second cumulative preview"); + let event = subscriber + .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(second)) + .await + .expect("second preview is accepted") + .expect("the matching buffered log is released"); + let SubscriberEvent::PreconfirmedLogs { flashblock, logs } = event else { + panic!("expected a preconfirmed log batch") + }; + assert_eq!(flashblock.block_number, 102); + assert_ne!(flashblock.content_hash, B256::ZERO); + assert_eq!(flashblock.partial_block_hash, None); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].transaction_hash, Some(B256::repeat_byte(0x42))); + assert_eq!(logs[0].block_hash, Some(flashblock.content_hash)); + } + + #[test] + fn flashblock_endpoints_certify_canonical_heads_instead_of_trusting_newheads() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("base-paid", 7)); + subscriber.chain_id = Some(8_453); + subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())]; + + let sources = subscriber.pubsub_stream_sources(); + assert!( + sources + .iter() + .any(|source| matches!(source, SubscriberStreamSource::CanonicalHeadPolling)) + ); + assert!( + !sources + .iter() + .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders)) + ); + } + + #[tokio::test] + async fn certified_canonical_heads_are_deduplicated_and_reject_placeholder_hashes() { + let asserter = Asserter::new(); + let certified = rpc_block(101, B256::repeat_byte(0x65)); + asserter.push_success(&Some(certified.clone())); + asserter.push_success(&Some(certified)); + asserter.push_success(&Some(rpc_block(102, B256::ZERO))); + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ); + + assert!(matches!( + subscriber + .fetch_certified_canonical_head() + .await + .expect("first certified head"), + Some(SubscriberEvent::BlockHeader(_)) + )); + assert!( + subscriber + .fetch_certified_canonical_head() + .await + .expect("duplicate certified head") + .is_none() + ); + assert!(matches!( + subscriber.fetch_certified_canonical_head().await, + Err(SubscriberError::Provider(ref message)) + if message.contains("placeholder hash") + )); + } + + #[tokio::test] + async fn optimism_canonical_head_is_the_exact_parent_of_pending() { + let asserter = Asserter::new(); + queue_op_pending(&asserter, rpc_block(101, B256::ZERO)); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ); + subscriber.chain_id = Some(10); + subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())]; + + let event = subscriber + .fetch_certified_canonical_head() + .await + .expect("OP pending parent can be certified") + .expect("the first certified parent is emitted"); + let SubscriberEvent::BlockHeader(header) = event else { + panic!("expected a certified canonical block header") + }; + assert_eq!(header.number(), 100); + assert_eq!(header.hash, B256::repeat_byte(0x64)); + assert_eq!( + subscriber + .flashblocks_rpc_metrics() + .pending_block_requests(), + 1 + ); + assert_eq!( + subscriber + .flashblocks_rpc_metrics() + .canonical_head_requests(), + 1 + ); + assert!(asserter.read_q().is_empty()); + } + + #[test] + fn optimism_uses_one_bounded_pending_state_stream() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 11)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(Address::repeat_byte(0x42)), + local_matcher: None, + route_key: None, + })]; + subscriber.interests = subscriber.base_interests.clone(); + + let sources = subscriber.pubsub_stream_sources(); + assert_eq!( + sources + .iter() + .filter(|source| matches!(source, SubscriberStreamSource::OpPendingFlashblocks)) + .count(), + 1 + ); + assert!(sources.iter().all(|source| !matches!( + source, + SubscriberStreamSource::BaseFlashblocks | SubscriberStreamSource::BasePendingLog { .. } + ))); + } + + #[test] + fn optimism_default_receipt_budget_reserves_every_fixed_sampler_method() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + // At 250 ms, the sampler reserves 4 * (exact parent + pending block + + // one filtered log request) = 12 methods. The remaining 28 exact + // receipt methods stay below the configured 40-method ceiling. + assert_eq!( + subscriber.pending_receipt_requests_per_second_capacity(), + 28 + ); + assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 7); + + subscriber + .interests + .push(ReactiveInterest::Blocks(BlockInterest::default())); + assert_eq!( + subscriber.pending_receipt_requests_per_second_capacity(), + 24 + ); + assert_eq!(subscriber.pending_receipt_requests_per_tick_capacity(), 6); + subscriber.interests.pop(); + + for _ in 0..4 { + assert!(subscriber.reserve_flashblock_rpc_methods(3)); + assert_eq!(subscriber.pending_receipt_request_allowance(), 7); + assert!(subscriber.reserve_flashblock_rpc_methods(7)); + } + assert!(!subscriber.reserve_flashblock_rpc_methods(1)); + subscriber.reset_flashblock_tracking(); + assert!( + !subscriber.reserve_flashblock_rpc_methods(1), + "a reconnect must not reset an endpoint's rolling quota window" + ); + } + + #[test] + fn flashblocks_config_rejects_a_zero_rpc_budget() { + let config = SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + max_flashblock_rpc_requests_per_second: 0, + ..SubscriberConfig::default() + }; + + assert!(matches!( + validate_subscriber_config(&config), + Err(SubscriberError::InvalidConfig( + "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero" + )) + )); + } + + #[tokio::test] + async fn optimism_preflight_rejects_a_budget_without_receipt_capacity() { + let asserter = Asserter::new(); + asserter.push_success(&serde_json::json!(["flashblocksv1"])); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + // Four ticks reserve three fixed methods each. Three remaining + // methods cannot fund even one receipt on every tick. + max_flashblock_rpc_requests_per_second: 15, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + let desired = subscriber.pubsub_stream_sources(); + let mut streams = SubscriberStreams::new(); + for source in desired { + streams.push(source, stream::pending().boxed()); + } + subscriber.state = AlloySubscriberState::Active(streams); + subscriber.sources_dirty = false; + assert!(matches!( + subscriber.establish_flashblocks_preflight(10).await, + Err(SubscriberError::InvalidConfig(message)) + if message.contains("leaves no capacity for OP transaction receipts") + )); + assert!(asserter.read_q().is_empty()); + } + + #[cfg(feature = "reactive-ws")] + #[tokio::test] + async fn flashblocks_preflight_proves_chain_and_both_subscription_lanes() { + let asserter = Asserter::new(); + asserter.push_success(&serde_json::json!({"flashblocks": true})); + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("base-paid", 7)); + subscriber.chain_id = Some(8_453); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + let desired = subscriber.pubsub_stream_sources(); + let mut streams = SubscriberStreams::new(); + for source in desired { + streams.push(source, stream::pending().boxed()); + } + subscriber.state = AlloySubscriberState::Active(streams); + subscriber.sources_dirty = false; + + let preflight = subscriber + .establish_flashblocks_preflight(8_453) + .await + .expect("preflight succeeds"); + + assert_eq!(preflight.chain_id(), 8_453); + assert_eq!(preflight.provider(), &ProviderRef::new("base-paid", 7)); + assert_eq!( + preflight.delivery(), + FlashblocksDelivery::NativeSubscriptions + ); + assert_eq!(preflight.pending_log_subscriptions(), 1); + assert_eq!(preflight.pending_log_filters(), 1); + assert_eq!( + preflight.advertised_capabilities(), + Some(&serde_json::json!({"flashblocks": true})) + ); + } + + #[tokio::test] + async fn optimism_preflight_probes_pending_state_without_native_subscriptions() { + let asserter = Asserter::new(); + asserter.push_success(&serde_json::json!(["flashblocksv1"])); + queue_op_pending(&asserter, rpc_block(101, B256::ZERO)); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::json!([])); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + let desired = subscriber.pubsub_stream_sources(); + let mut streams = SubscriberStreams::new(); + for source in desired { + streams.push(source, stream::pending().boxed()); + } + subscriber.state = AlloySubscriberState::Active(streams); + subscriber.sources_dirty = false; + + let preflight = subscriber + .establish_flashblocks_preflight(10) + .await + .expect("Optimism pending-state preflight succeeds"); + + assert_eq!(preflight.chain_id(), 10); + assert_eq!(preflight.provider(), &ProviderRef::new("op-paid", 12)); + assert_eq!( + preflight.delivery(), + FlashblocksDelivery::PendingStatePolling + ); + assert_eq!(preflight.pending_log_subscriptions(), 0); + assert_eq!(preflight.pending_log_filters(), 1); + assert_eq!( + preflight.advertised_capabilities(), + Some(&serde_json::json!(["flashblocksv1"])) + ); + assert!(asserter.read_q().is_empty()); + } + + #[test] + fn optimism_full_pending_block_normalizes_op_transaction_types_to_hashes() { + let transaction_hash = B256::repeat_byte(0x7e); + let mut value = serde_json::to_value(rpc_block(101, B256::ZERO)) + .expect("serialize pending block fixture"); + value["transactions"] = serde_json::json!([{ + "type": "0x7e", + "hash": transaction_hash, + "sourceHash": B256::repeat_byte(0x11), + "from": Address::repeat_byte(0x22), + "to": Address::repeat_byte(0x33) + }]); + + let block = normalize_op_pending_block::(value) + .expect("OP-specific transaction bodies are reduced to hashes"); + + assert_eq!( + block.transactions().as_hashes(), + Some(&[transaction_hash][..]) + ); + } + + #[tokio::test] + async fn optimism_sampler_does_not_retry_malformed_pending_content() { + let asserter = Asserter::new(); + let mut pending = serde_json::to_value(rpc_block(101, B256::ZERO)) + .expect("serialize pending block fixture"); + pending["transactions"] = serde_json::json!([{"type": "0x7e"}]); + asserter.push_success(&Some(pending)); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + + let error = match subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + { + Err(error) => error, + Ok(_) => panic!("malformed provider content must fail immediately"), + }; + assert!( + error + .to_string() + .contains("transaction is missing its hash") + ); + assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_certifies_the_pending_block_by_exact_parent_hash() { + let asserter = Asserter::new(); + queue_op_pending(&asserter, rpc_block(101, B256::ZERO)); + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + + assert!( + subscriber + .fetch_pending_flashblock(None) + .await + .expect("the exact parent certifies the pending payload") + .is_some() + ); + } + + #[tokio::test] + async fn optimism_sampler_rejects_a_nonconsecutive_pending_parent() { + let asserter = Asserter::new(); + asserter.push_success(&Some(rpc_block(101, B256::ZERO))); + asserter.push_success(&Some(rpc_block(99, B256::repeat_byte(0x64)))); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + + assert!(matches!( + subscriber.fetch_pending_flashblock(None).await, + Err(PendingFlashblockPollError::Integrity(SubscriberError::Provider( + ref message + ))) if message.contains("does not extend its exact certified parent") + )); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_rechecks_unchanged_content_without_republishing_logs() { + let asserter = Asserter::new(); + let pending = rpc_block(101, B256::ZERO); + queue_op_pending(&asserter, pending.clone()); + asserter.push_success(&Vec::::new()); + queue_op_pending(&asserter, pending); + asserter.push_success(&Vec::::new()); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + assert!( + subscriber + .fetch_pending_flashblock(None) + .await + .expect("first cumulative pending view") + .is_some() + ); + assert!( + subscriber + .fetch_pending_flashblock(None) + .await + .expect("duplicate cumulative pending view") + .is_none() + ); + + assert_eq!( + subscriber.flashblocks_rpc_metrics(), + FlashblocksRpcMetrics { + capability_requests: 0, + provider_pair_chain_requests: 0, + canonical_head_requests: 2, + pending_block_requests: 2, + pending_log_requests: 2, + pending_receipt_requests: 0, + pending_receipts_completed: 0, + pending_receipts_unavailable: 0, + failed_requests: 0, + raced_samples: 0, + } + ); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_rechecks_logs_for_an_unchanged_pending_view() { + let asserter = Asserter::new(); + let transaction = B256::repeat_byte(0x42); + let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]), + ); + let mut log = rpc_log(false); + log.block_number = Some(101); + log.block_hash = Some(B256::repeat_byte(0xa2)); + log.transaction_hash = Some(transaction); + log.transaction_index = Some(0); + log.log_index = Some(0); + queue_op_pending(&asserter, pending.clone()); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::Value::Null); + queue_op_pending(&asserter, pending); + asserter.push_success(&vec![log]); + asserter.push_success(&serde_json::Value::Null); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + assert!(matches!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("first pending view is coherent"), + Some(SubscriberEvent::FlashblockObserved) + )); + assert!(matches!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("the unchanged view is checked again for lagging logs"), + Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1 + )); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_hydrates_exact_receipts_when_filtered_logs_are_empty() { + let asserter = Asserter::new(); + let transaction = B256::repeat_byte(0x42); + let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]), + ); + let mut log = rpc_log(false); + log.block_number = Some(101); + log.block_hash = Some(B256::repeat_byte(0xa2)); + log.transaction_hash = Some(transaction); + log.transaction_index = Some(0); + log.log_index = Some(0); + queue_op_pending(&asserter, pending); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::json!({ + "transactionHash": transaction, + "logs": [log] + })); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + let event = subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("pending receipt fallback succeeds"); + assert!(matches!( + event, + Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1 + )); + assert_eq!( + subscriber + .flashblocks_rpc_metrics() + .pending_receipt_requests(), + 1 + ); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_receipt_hydration_is_bounded_and_resumes_on_the_next_tick() { + let asserter = Asserter::new(); + let transaction_a = B256::repeat_byte(0x41); + let transaction_b = B256::repeat_byte(0x42); + let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![ + transaction_a, + transaction_b, + ]), + ); + let mut log = rpc_log(false); + log.block_number = Some(101); + log.transaction_hash = Some(transaction_b); + log.transaction_index = Some(1); + queue_op_pending(&asserter, pending.clone()); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::json!({ + "transactionHash": transaction_a, + "logs": [] + })); + queue_op_pending(&asserter, pending); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::json!({ + "transactionHash": transaction_b, + "logs": [log] + })); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + max_pending_transaction_receipts_per_tick: 1, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + assert!(matches!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("the first bounded receipt is hydrated"), + Some(SubscriberEvent::FlashblockObserved) + )); + assert!(matches!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("the remaining receipt is hydrated on the next tick"), + Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1 + )); + assert_eq!( + subscriber + .flashblocks_rpc_metrics() + .pending_receipt_requests(), + 2 + ); + assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_receipt_hydration_prioritizes_unattempted_hashes_over_null_retries() { + let asserter = Asserter::new(); + let transaction_a = B256::repeat_byte(0x41); + let transaction_b = B256::repeat_byte(0x42); + let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![ + transaction_a, + transaction_b, + ]), + ); + let mut log = rpc_log(false); + log.block_number = Some(101); + log.transaction_hash = Some(transaction_b); + log.transaction_index = Some(1); + queue_op_pending(&asserter, pending.clone()); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::Value::Null); + queue_op_pending(&asserter, pending); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::json!({ + "transactionHash": transaction_b, + "logs": [log] + })); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + max_pending_transaction_receipts_per_tick: 1, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + assert!(matches!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("the first null receipt remains retryable"), + Some(SubscriberEvent::FlashblockObserved) + )); + assert!(matches!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("the next unattempted receipt is not starved"), + Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1 + )); + assert!( + subscriber + .preconfirmed_unavailable_receipts + .contains(&transaction_a) + ); + assert!( + subscriber + .preconfirmed_receipted_transactions + .contains(&transaction_b) + ); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_receipt_batch_commits_dedupe_only_after_every_response_succeeds() { + let asserter = Asserter::new(); + let transaction_a = B256::repeat_byte(0x41); + let transaction_b = B256::repeat_byte(0x42); + let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![ + transaction_a, + transaction_b, + ]), + ); + let mut log = rpc_log(false); + log.block_number = Some(101); + log.transaction_hash = Some(transaction_b); + log.transaction_index = Some(1); + queue_op_pending(&asserter, pending.clone()); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::json!({ + "transactionHash": transaction_a, + "logs": [] + })); + asserter.push_failure_msg("receipt temporarily unavailable"); + queue_op_pending(&asserter, pending); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::json!({ + "transactionHash": transaction_a, + "logs": [] + })); + asserter.push_success(&serde_json::json!({ + "transactionHash": transaction_b, + "logs": [log] + })); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + max_pending_transaction_receipts_per_tick: 2, + max_consecutive_flashblock_poll_failures: 2, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + assert!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("one failed receipt response remains retryable") + .is_none() + ); + assert!(subscriber.preconfirmed_receipted_transactions.is_empty()); + assert!(matches!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("the complete batch is retried transactionally"), + Some(SubscriberEvent::PreconfirmedLogs { ref logs, .. }) if logs.len() == 1 + )); + assert_eq!(subscriber.preconfirmed_receipted_transactions.len(), 2); + assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1); + assert_eq!( + subscriber + .flashblocks_rpc_metrics() + .pending_receipt_requests(), + 4 + ); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_rejects_a_receipt_for_a_different_transaction() { + let asserter = Asserter::new(); + let sampled_transaction = B256::repeat_byte(0x41); + let advanced_transaction = B256::repeat_byte(0x42); + let pending = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![sampled_transaction]), + ); + let mut log = rpc_log(false); + log.block_number = Some(101); + log.transaction_hash = Some(advanced_transaction); + queue_op_pending(&asserter, pending); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::json!({ + "transactionHash": advanced_transaction, + "logs": [log] + })); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + let error = match subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + { + Err(error) => error, + Ok(_) => panic!("a receipt for another transaction must fail closed"), + }; + assert!( + error + .to_string() + .contains("hash disagrees with its request") + ); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_revokes_then_recovers_from_a_regressive_pending_view() { + let asserter = Asserter::new(); + let transaction_a = B256::repeat_byte(0x41); + let transaction_b = B256::repeat_byte(0x42); + let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![ + transaction_a, + transaction_b, + ]), + ); + let regressive = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]), + ); + queue_op_pending(&asserter, first); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::Value::Null); + asserter.push_success(&serde_json::Value::Null); + queue_op_pending(&asserter, regressive.clone()); + queue_op_pending(&asserter, regressive); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::Value::Null); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + assert!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("first pending view is coherent") + .is_some() + ); + assert!(matches!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("regression revokes instead of terminating the stream"), + Some(SubscriberEvent::FlashblockInvalidated) + )); + assert!(subscriber.latest_preconfirmation.is_none()); + assert!(subscriber.pending_preconfirmation_invalidation); + assert!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("a later coherent view establishes a fresh snapshot") + .is_some() + ); + assert!(subscriber.latest_preconfirmation.is_some()); + assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 12); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_new_quiet_payload_revokes_the_previous_snapshot() { + let asserter = Asserter::new(); + let first = rpc_block(101, B256::repeat_byte(0xa1)); + let second = rpc_block(102, B256::repeat_byte(0xa2)); + queue_op_pending(&asserter, first); + asserter.push_success(&Vec::::new()); + queue_op_pending(&asserter, second); + asserter.push_success(&Vec::::new()); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("first quiet payload is observed"); + assert!(!subscriber.pending_preconfirmation_invalidation); + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("replacement quiet payload is observed"); + assert!(subscriber.pending_preconfirmation_invalidation); + assert_eq!( + subscriber + .latest_preconfirmation + .as_ref() + .map(|flashblock| flashblock.block_number), + Some(102) + ); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_rejects_malformed_pending_receipts() { + let asserter = Asserter::new(); + let transaction = B256::repeat_byte(0x42); + let pending = rpc_block(101, B256::ZERO).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![transaction]), + ); + queue_op_pending(&asserter, pending); + asserter.push_success(&Vec::::new()); + asserter.push_success(&serde_json::json!({"transactionHash": transaction})); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + let error = match subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + { + Err(error) => error, + Ok(_) => panic!("malformed receipt content must fail closed"), + }; + assert!(error.to_string().contains("missing its log array")); + assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_retries_when_logs_advance_past_the_sampled_block() { + let asserter = Asserter::new(); + let transaction_a = B256::repeat_byte(0x41); + let transaction_b = B256::repeat_byte(0x42); + let first = rpc_block(101, B256::repeat_byte(0xa1)).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![transaction_a]), + ); + let second = rpc_block(101, B256::repeat_byte(0xa2)).with_transactions( + alloy_network::primitives::BlockTransactions::Hashes(vec![ + transaction_a, + transaction_b, + ]), + ); + let mut log = rpc_log(false); + log.block_number = Some(101); + log.block_hash = Some(B256::repeat_byte(0xa2)); + log.transaction_hash = Some(transaction_b); + log.transaction_index = Some(1); + log.log_index = Some(0); + queue_op_pending(&asserter, first); + asserter.push_success(&vec![log.clone()]); + asserter.push_success(&serde_json::Value::Null); + queue_op_pending(&asserter, second); + asserter.push_success(&vec![log.clone()]); + asserter.push_success(&serde_json::Value::Null); + asserter.push_success(&serde_json::json!({ + "transactionHash": transaction_b, + "logs": [log] + })); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + assert!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("a cross-request race remains retryable") + .is_none() + ); + assert!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("the next coherent cumulative view is delivered") + .is_some() + ); + assert_eq!(subscriber.flashblocks_rpc_metrics().raced_samples(), 1); + assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 0); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_uses_the_paired_pending_state_provider() { + let stream_asserter = Asserter::new(); + let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone()); + let state_asserter = Asserter::new(); + queue_op_pending(&state_asserter, rpc_block(101, B256::ZERO)); + state_asserter.push_success(&Vec::::new()); + let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + stream_provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)) + .with_flashblocks_state_provider(state_provider); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + assert!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("paired pending-state reads succeed") + .is_some() + ); + assert!(state_asserter.read_q().is_empty()); + assert!(stream_asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_retries_an_isolated_provider_request_failure() { + let asserter = Asserter::new(); + let pending = rpc_block(101, B256::ZERO); + queue_op_pending(&asserter, pending.clone()); + asserter.push_failure_msg("temporarily unavailable"); + queue_op_pending(&asserter, pending); + asserter.push_success(&Vec::::new()); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + max_consecutive_flashblock_poll_failures: 2, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + + assert!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("one request failure stays retryable") + .is_none() + ); + assert!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("the next cumulative view retries the missing logs") + .is_some() + ); + assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 1); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn optimism_sampler_surfaces_sustained_provider_request_failures() { + let asserter = Asserter::new(); + asserter.push_failure_msg("temporarily unavailable"); + asserter.push_failure_msg("still unavailable"); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + max_consecutive_flashblock_poll_failures: 2, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("op-paid", 12)); + subscriber.chain_id = Some(10); + + assert!( + subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + .expect("the first request failure stays retryable") + .is_none() + ); + let error = match subscriber + .normalize_flashblock_event(SubscriberEvent::OpFlashblockTick) + .await + { + Err(error) => error, + Ok(_) => panic!("the configured consecutive-failure limit must fail closed"), + }; + assert!(error.to_string().contains("still unavailable")); + assert_eq!(subscriber.flashblocks_rpc_metrics().failed_requests(), 2); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + async fn flashblocks_preflight_rejects_a_mismatched_chain_before_subscribing() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("wrong-chain", 1)); + subscriber.chain_id = Some(10); + subscriber.interests = vec![log_interest_matching_rpc_log()]; + + assert!(matches!( + subscriber.establish_flashblocks_preflight(8_453).await, + Err(SubscriberError::ChainMismatch { + expected: 8_453, + actual: 10 + }) + )); + } + + #[tokio::test] + async fn optimism_preflight_rejects_a_mismatched_paired_provider() { + let stream_asserter = Asserter::new(); + let stream_provider = ProviderBuilder::new().connect_mocked_client(stream_asserter.clone()); + let state_asserter = Asserter::new(); + state_asserter.push_success(&serde_json::json!(["flashblocksv1"])); + state_asserter.push_success(&8_453_u64); + let state_provider = ProviderBuilder::new().connect_mocked_client(state_asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + stream_provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, ) - .expect("decode current Base newFlashblocks shape"); - let BaseFlashblockWirePayload::Block(payload) = payload else { - panic!("expected cumulative block-shaped payload") - }; - assert_eq!(payload.number, 49_233_979); - assert_eq!(payload.timestamp, 1_785_257_305); - assert_eq!(payload.hash, B256::repeat_byte(0xaa)); - assert_eq!(payload.parent_hash, B256::repeat_byte(0xbb)); - assert_eq!(payload.state_root, B256::repeat_byte(0xcc)); + .with_provider_ref(ProviderRef::new("op-paid", 12)) + .with_flashblocks_state_provider(state_provider); + subscriber.chain_id = Some(10); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + let desired = subscriber.pubsub_stream_sources(); + let mut streams = SubscriberStreams::new(); + for source in desired { + streams.push(source, stream::pending().boxed()); + } + subscriber.state = AlloySubscriberState::Active(streams); + subscriber.sources_dirty = false; + assert!(matches!( + subscriber.establish_flashblocks_preflight(10).await, + Err(SubscriberError::ChainMismatch { + expected: 10, + actual: 8_453 + }) + )); + assert!(state_asserter.read_q().is_empty()); + assert!(stream_asserter.read_q().is_empty()); } #[test] @@ -15906,6 +19321,15 @@ mod subscriber_helper_tests { }) } + fn queue_op_pending(asserter: &Asserter, pending: alloy_rpc_types_eth::Block) { + let parent = rpc_block( + pending.header().number().saturating_sub(1), + pending.header().parent_hash(), + ); + asserter.push_success(&Some(pending)); + asserter.push_success(&Some(parent)); + } + #[tokio::test(flavor = "multi_thread")] #[cfg(feature = "reactive-ws")] async fn verified_log_context_fetches_and_caches_exact_parent_identity() { @@ -17321,6 +20745,334 @@ mod subscriber_helper_tests { ); } + #[tokio::test] + #[cfg(feature = "reactive-ws")] + async fn flashblock_stream_termination_invalidates_before_reconnect_io() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("base-paid", 7)); + subscriber.chain_id = Some(8_453); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + subscriber.sources_dirty = false; + + let preview: BaseFlashblockWirePayload = serde_json::from_str( + r#"{ + "hash":"0x0000000000000000000000000000000000000000000000000000000000000000", + "number":"0x65", + "parentHash":"0x6464646464646464646464646464646464646464646464646464646464646464", + "stateRoot":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "transactionsRoot":"0x1111111111111111111111111111111111111111111111111111111111111111", + "timestamp":"0x6553f165", + "transactions":["0x4141414141414141414141414141414141414141414141414141414141414141"] + }"#, + ) + .unwrap(); + let (preview, _) = subscriber.accept_base_flashblock(preview).unwrap(); + subscriber.latest_preconfirmation = Some(preview); + + let mut streams = SubscriberStreams::new(); + streams.push( + SubscriberStreamSource::BaseFlashblocks, + stream::once(async { + SubscriberEvent::::StreamTerminated( + SubscriberStreamSource::BaseFlashblocks, + ) + }) + .boxed(), + ); + subscriber.state = AlloySubscriberState::Active(streams); + + let batch = subscriber + .next_scoped_batch() + .await + .expect("termination handling succeeds") + .expect("invalidation is delivered"); + assert!(batch.preconfirmation_invalidated()); + assert!(subscriber.latest_preconfirmation.is_none()); + assert_eq!(subscriber.provider_ref.as_ref().unwrap().generation, 8); + assert_eq!(subscriber.pending_flashblock_reconnects.len(), 2); + assert!( + subscriber + .pending_flashblock_reconnect_sources + .iter() + .any(|source| matches!(source, SubscriberStreamSource::BaseFlashblocks)) + ); + assert!( + subscriber + .pending_flashblock_reconnect_sources + .iter() + .any(|source| matches!(source, SubscriberStreamSource::BasePendingLog { .. })) + ); + let AlloySubscriberState::Active(streams) = &subscriber.state else { + panic!("subscriber remains active while reconnect is pending") + }; + assert!( + streams + .entries + .iter() + .all(|entry| !entry.source.is_flashblocks()) + ); + } + + #[tokio::test] + #[cfg(feature = "reactive-ws")] + async fn preferred_initial_flashblock_rejection_retains_canonical_streams() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let filter = Filter::new().address(Address::repeat_byte(0x42)); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + reconnect: SubscriberReconnectConfig { + enabled: false, + ..SubscriberReconnectConfig::default() + }, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("base-paid", 1)); + subscriber.chain_id = Some(8_453); + subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest { + provider_filter: filter.clone(), + local_matcher: None, + route_key: None, + })]; + subscriber.interests = subscriber.base_interests.clone(); + subscriber.log_source_ids.insert(filter.clone(), 0); + subscriber.next_log_source_id = 1; + + let canonical_source = SubscriberStreamSource::PubSubLog { + id: 0, + filter: filter.clone(), + }; + let mut streams = SubscriberStreams::new(); + streams.push( + canonical_source.clone(), + stream::pending::>().boxed(), + ); + subscriber.state = AlloySubscriberState::Active(streams); + subscriber.sources_dirty = true; + + subscriber + .ensure_streams() + .await + .expect("preferred Flashblocks setup degrades to canonical-only"); + let AlloySubscriberState::Active(streams) = &subscriber.state else { + panic!("canonical stream remains active") + }; + assert!(streams.contains_source(&canonical_source)); + assert!( + streams + .entries + .iter() + .all(|entry| !entry.source.is_flashblocks()) + ); + assert!(subscriber.pending_flashblock_reconnects.is_empty()); + assert!(!subscriber.sources_dirty); + } + + #[tokio::test] + #[cfg(feature = "reactive-ws")] + async fn required_initial_flashblock_rejection_remains_fail_closed() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let filter = Filter::new().address(Address::repeat_byte(0x42)); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + reconnect: SubscriberReconnectConfig { + enabled: false, + ..SubscriberReconnectConfig::default() + }, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("base-paid", 1)); + subscriber.chain_id = Some(8_453); + subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest { + provider_filter: filter.clone(), + local_matcher: None, + route_key: None, + })]; + subscriber.interests = subscriber.base_interests.clone(); + subscriber.log_source_ids.insert(filter.clone(), 0); + subscriber.next_log_source_id = 1; + + let canonical_source = SubscriberStreamSource::PubSubLog { + id: 0, + filter: filter.clone(), + }; + let mut streams = SubscriberStreams::new(); + streams.push( + canonical_source.clone(), + stream::pending::>().boxed(), + ); + subscriber.state = AlloySubscriberState::Active(streams); + subscriber.sources_dirty = true; + + let error = subscriber + .ensure_streams() + .await + .expect_err("required Flashblocks setup must fail closed"); + assert!(matches!(error, SubscriberError::Provider(_))); + let AlloySubscriberState::Active(streams) = &subscriber.state else { + panic!("the already-connected canonical stream is retained") + }; + assert!(streams.contains_source(&canonical_source)); + } + + #[tokio::test] + #[cfg(feature = "reactive-ws")] + async fn preferred_flashblock_termination_preserves_canonical_delivery() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let filter = Filter::new().address(Address::repeat_byte(0x42)); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + reconnect: SubscriberReconnectConfig { + enabled: false, + ..SubscriberReconnectConfig::default() + }, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("base-paid", 1)); + subscriber.chain_id = Some(8_453); + subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest { + provider_filter: filter.clone(), + local_matcher: None, + route_key: None, + })]; + subscriber.interests = subscriber.base_interests.clone(); + subscriber.log_source_ids.insert(filter.clone(), 0); + subscriber.next_log_source_id = 1; + subscriber.sources_dirty = false; + + let mut streams = SubscriberStreams::new(); + streams.push( + SubscriberStreamSource::BaseFlashblocks, + stream::once(async { + SubscriberEvent::::StreamTerminated( + SubscriberStreamSource::BaseFlashblocks, + ) + }) + .boxed(), + ); + streams.push( + SubscriberStreamSource::PubSubLog { + id: 0, + filter: filter.clone(), + }, + stream::once(async { + SubscriberEvent::::Log { + source_id: 0, + log: rpc_log(false), + } + }) + .boxed(), + ); + subscriber.state = AlloySubscriberState::Active(streams); + + let invalidation = subscriber + .next_scoped_batch() + .await + .expect("preferred termination does not fail") + .expect("invalidation is delivered"); + assert!(invalidation.preconfirmation_invalidated()); + + let canonical = subscriber + .next_scoped_batch() + .await + .expect("canonical stream remains healthy") + .expect("canonical log is delivered"); + assert!(!canonical.preconfirmation_invalidated()); + assert_eq!(canonical.records().len(), 1); + assert_eq!( + canonical.records()[0].record.context.source, + InputSource::Subscription + ); + } + + #[tokio::test] + #[cfg(feature = "reactive-ws")] + async fn preferred_flashblock_reconnect_exhaustion_preserves_canonical_delivery() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let filter = Filter::new().address(Address::repeat_byte(0x42)); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + reconnect: SubscriberReconnectConfig { + enabled: false, + ..SubscriberReconnectConfig::default() + }, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("base-paid", 1)); + subscriber.chain_id = Some(8_453); + subscriber.base_interests = vec![ReactiveInterest::Logs(LogInterest { + provider_filter: filter.clone(), + local_matcher: None, + route_key: None, + })]; + subscriber.interests = subscriber.base_interests.clone(); + subscriber.log_source_ids.insert(filter.clone(), 0); + subscriber.next_log_source_id = 1; + subscriber.sources_dirty = false; + + let canonical_source = SubscriberStreamSource::PubSubLog { id: 0, filter }; + let mut streams = SubscriberStreams::new(); + streams.push( + canonical_source, + stream::once(async { + tokio::time::sleep(Duration::from_millis(1)).await; + SubscriberEvent::::Log { + source_id: 0, + log: rpc_log(false), + } + }) + .boxed(), + ); + subscriber.state = AlloySubscriberState::Active(streams); + + let source = SubscriberStreamSource::BaseFlashblocks; + subscriber + .pending_flashblock_reconnect_sources + .push(source.clone()); + subscriber + .pending_flashblock_reconnects + .push(Box::pin(async move { + ( + source, + Err(SubscriberError::Provider( + "test reconnect window exhausted".to_owned(), + )), + ) + })); + + let canonical = subscriber + .next_scoped_batch() + .await + .expect("preferred reconnect exhaustion does not fail") + .expect("canonical log is delivered"); + assert_eq!(canonical.records().len(), 1); + assert!(subscriber.pending_flashblock_reconnects.is_empty()); + } + #[test] fn backfilled_logs_skip_recent_subscription_duplicates() { let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); @@ -17808,7 +21560,7 @@ mod subscriber_helper_tests { } // A log interest matching `rpc_log` (address 0x42, topic0 0x01). - #[cfg(feature = "reactive-ws")] + #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))] fn log_interest_matching_rpc_log() -> ReactiveInterest { ReactiveInterest::Logs(LogInterest { provider_filter: Filter::new() @@ -18218,6 +21970,13 @@ fn resolve_auto_subscriber_transport() -> Result Result<(), SubscriberError> { + if config.preconfirmations != PreconfirmationMode::Disabled + && config.canonical_head_poll_interval.is_zero() + { + return Err(SubscriberError::InvalidConfig( + "SubscriberConfig::canonical_head_poll_interval must be greater than zero", + )); + } if config.preconfirmations != PreconfirmationMode::Disabled && config.flashblock_poll_interval.is_zero() { @@ -18225,6 +21984,27 @@ fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), Subscribe "SubscriberConfig::flashblock_poll_interval must be greater than zero", )); } + if config.preconfirmations != PreconfirmationMode::Disabled + && config.max_consecutive_flashblock_poll_failures == 0 + { + return Err(SubscriberError::InvalidConfig( + "SubscriberConfig::max_consecutive_flashblock_poll_failures must be greater than zero", + )); + } + if config.preconfirmations != PreconfirmationMode::Disabled + && config.max_pending_transaction_receipts_per_tick == 0 + { + return Err(SubscriberError::InvalidConfig( + "SubscriberConfig::max_pending_transaction_receipts_per_tick must be greater than zero", + )); + } + if config.preconfirmations != PreconfirmationMode::Disabled + && config.max_flashblock_rpc_requests_per_second == 0 + { + return Err(SubscriberError::InvalidConfig( + "SubscriberConfig::max_flashblock_rpc_requests_per_second must be greater than zero", + )); + } if config.max_batch_size == 0 { return Err(SubscriberError::InvalidConfig( "SubscriberConfig::max_batch_size must be greater than zero", @@ -18714,7 +22494,9 @@ fn preconfirmed_log_input_record( ReactiveContext { chain_id: None, source: InputSource::Flashblocks, - chain_status: ChainStatus::Preconfirmed { flashblock }, + chain_status: ChainStatus::Preconfirmed { + flashblock: Arc::new(flashblock), + }, block: Some(block), transaction_index: log.transaction_index, log_index: log.log_index, @@ -18825,6 +22607,14 @@ pub enum SubscriberError { /// Requested subscriber behavior is not implemented. #[error("{0}")] Unsupported(&'static str), + /// The pinned provider lease reports a different chain identity. + #[error("subscriber chain mismatch: expected {expected}, got {actual}")] + ChainMismatch { + /// Required chain id. + expected: u64, + /// Observed chain id. + actual: u64, + }, /// Provider or transport error. #[error("provider error: {0}")] Provider(String), diff --git a/tests/freshness.rs b/tests/freshness.rs index 6b91485..e31a7db 100644 --- a/tests/freshness.rs +++ b/tests/freshness.rs @@ -293,6 +293,12 @@ async fn overlay_call_raw_with_access_list_captures_read_set() -> Result<()> { assert!(result.is_success(), "balanceOf should succeed: {result:?}"); assert!(access.accounts.contains(&token), "token account touched"); + assert!( + access + .code_hashes + .contains(&common::mock_erc20_runtime().hash_slow()), + "executed runtime code captured in read set" + ); // The hashed balance slot for owner should be in the read set. let hashed = { use alloy_sol_types::SolValue; diff --git a/tests/reactive_flashblocks.rs b/tests/reactive_flashblocks.rs index e465c4c..4d8d505 100644 --- a/tests/reactive_flashblocks.rs +++ b/tests/reactive_flashblocks.rs @@ -4,6 +4,7 @@ mod common; use std::sync::Arc; +use alloy_eips::BlockId; use alloy_network::Ethereum; use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256}; use alloy_rpc_types_eth::{Filter, Log}; @@ -24,10 +25,17 @@ fn flashblock(provider: ProviderRef, index: u64, hash: B256) -> FlashblockRef { payload_id: Some([0x11; 8].into()), index: Some(index), block_number: 101, - block_hash: hash, + content_hash: hash, + partial_block_hash: Some(hash), parent_hash: Some(B256::repeat_byte(0x64)), state_root: Some(B256::repeat_byte(0xaa)), + transactions_root: Some(B256::repeat_byte(0x91)), + transaction_hashes: vec![B256::repeat_byte(0x41)], timestamp: Some(1_700_000_101), + base_fee_per_gas: Some(7), + beneficiary: Some(Address::repeat_byte(0xcb)), + prevrandao: Some(B256::repeat_byte(0x77)), + gas_limit: Some(30_000_000), } } @@ -52,7 +60,9 @@ fn preconfirmed_record(address: Address, flashblock: FlashblockRef) -> ReactiveI ReactiveContext { chain_id: Some(1), source: InputSource::Flashblocks, - chain_status: ChainStatus::Preconfirmed { flashblock }, + chain_status: ChainStatus::Preconfirmed { + flashblock: Arc::new(flashblock), + }, block: Some(block), transaction_index: Some(0), log_index: Some(0), @@ -129,8 +139,12 @@ fn base_flashblock_wire_decodes_decimal_index_and_hex_header_quantities() -> Res "index":4, "base":{ "parent_hash":"0x6464646464646464646464646464646464646464646464646464646464646464", + "fee_recipient":"0xcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcb", "block_number":"0x65", - "timestamp":"0x6553f165" + "gas_limit":"0x1c9c380", + "timestamp":"0x6553f165", + "base_fee_per_gas":"0x7", + "prev_randao":"0x7777777777777777777777777777777777777777777777777777777777777777" }, "diff":{ "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", @@ -140,16 +154,21 @@ fn base_flashblock_wire_decodes_decimal_index_and_hex_header_quantities() -> Res }"#, )?; assert_eq!(payload.index, 4); - assert_eq!(payload.base.expect("index-zero header").block_number, 101); + let base = payload.base.expect("index-zero header"); + assert_eq!(base.block_number, 101); + assert_eq!(base.gas_limit, Some(30_000_000)); + assert_eq!(base.base_fee_per_gas, Some(7)); + assert_eq!(base.beneficiary, Some(Address::repeat_byte(0xcb))); + assert_eq!(base.prevrandao, Some(B256::repeat_byte(0x77))); assert_eq!(payload.metadata.expect("metadata").block_number, 101); Ok(()) } #[test] -fn flashblocks_policy_is_disabled_by_default_and_op_polling_is_explicit() { +fn flashblocks_policy_is_disabled_by_default_and_canonical_certification_is_bounded() { let default = SubscriberConfig::default(); assert_eq!(default.preconfirmations, PreconfirmationMode::Disabled); - assert_eq!(default.flashblock_poll_interval.as_millis(), 100); + assert_eq!(default.canonical_head_poll_interval.as_millis(), 500); } #[tokio::test] @@ -204,3 +223,186 @@ async fn preconfirmed_updates_are_visible_then_discarded_before_canonical_ingest ); Ok(()) } + +#[tokio::test] +async fn preconfirmed_branch_installs_pending_rpc_pin_and_complete_block_environment() -> Result<()> +{ + let address = Address::repeat_byte(0x77); + let slot = U256::from(7); + let mut cache = setup_cache().await?; + cache.set_block(BlockId::number(100)); + cache.set_block_context(Some(100), Some(3)); + cache.set_coinbase(Some(Address::repeat_byte(0xca))); + cache.set_prevrandao(Some(B256::repeat_byte(0x66))); + cache.set_block_gas_limit(Some(29_000_000)); + cache.set_timestamp(Some(1_700_000_100)); + install_mock_erc20(&mut cache, address); + cache + .db_mut() + .insert_account_storage(address, slot, U256::from(1))?; + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(SlotWriter { + address, + slot, + value: U256::from(99), + }))?; + let pending = flashblock( + ProviderRef::new("base-flashblocks", 3), + 2, + B256::repeat_byte(0xfa), + ); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![preconfirmed_record(address, pending)]) + .with_delivery_scope(DeliveryScope::Preconfirmed), + )?; + + assert_eq!(cache.block(), BlockId::pending()); + assert_eq!(cache.block_number(), Some(101)); + assert_eq!(cache.basefee(), Some(7)); + assert_eq!(cache.coinbase(), Some(Address::repeat_byte(0xcb))); + assert_eq!(cache.prevrandao(), Some(B256::repeat_byte(0x77))); + assert_eq!(cache.block_gas_limit(), Some(30_000_000)); + assert_eq!(cache.timestamp(), Some(1_700_000_101)); + + runtime.discard_preconfirmation(&mut cache); + assert_eq!(cache.block(), BlockId::number(100)); + assert_eq!(cache.block_number(), Some(100)); + assert_eq!(cache.basefee(), Some(3)); + assert_eq!(cache.coinbase(), Some(Address::repeat_byte(0xca))); + assert_eq!(cache.prevrandao(), Some(B256::repeat_byte(0x66))); + assert_eq!(cache.block_gas_limit(), Some(29_000_000)); + assert_eq!(cache.timestamp(), Some(1_700_000_100)); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn cumulative_previews_preserve_generation_local_fills_without_leaking_them() -> Result<()> { + let event_address = Address::repeat_byte(0x77); + let canonical_warm_address = Address::repeat_byte(0x88); + let pending_fill_address = Address::repeat_byte(0x99); + let slot = U256::from(7); + let canonical_warm_value = U256::from(123); + let pending_fill_value = U256::from(456); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, event_address); + install_mock_erc20(&mut cache, canonical_warm_address); + install_mock_erc20(&mut cache, pending_fill_address); + cache + .db_mut() + .insert_account_storage(canonical_warm_address, slot, canonical_warm_value)?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(SlotWriter { + address: event_address, + slot, + value: U256::from(99), + }))?; + + let provider = ProviderRef::new("base-flashblocks", 3); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![preconfirmed_record( + event_address, + flashblock(provider.clone(), 1, B256::repeat_byte(0xf1)), + )]) + .with_delivery_scope(DeliveryScope::Preconfirmed), + )?; + cache + .db_mut() + .insert_account_storage(pending_fill_address, slot, pending_fill_value)?; + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![preconfirmed_record( + event_address, + flashblock(provider, 2, B256::repeat_byte(0xf2)), + )]) + .with_delivery_scope(DeliveryScope::Preconfirmed), + )?; + assert_eq!( + cache.cached_storage_value(pending_fill_address, slot), + Some(pending_fill_value), + "a cumulative successor reuses its generation-local pending read set" + ); + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![preconfirmed_record( + event_address, + flashblock( + ProviderRef::new("base-flashblocks", 4), + 0, + B256::repeat_byte(0xf3), + ), + )]) + .with_delivery_scope(DeliveryScope::Preconfirmed), + )?; + assert_eq!( + cache.cached_storage_value(pending_fill_address, slot), + Some(U256::ZERO) + ); + assert_eq!( + cache.cached_storage_value(canonical_warm_address, slot), + Some(canonical_warm_value), + "canonical warming survives every speculative replacement" + ); + + runtime.discard_preconfirmation(&mut cache); + assert_eq!( + cache.cached_storage_value(pending_fill_address, slot), + Some(U256::ZERO) + ); + assert_eq!( + cache.cached_storage_value(canonical_warm_address, slot), + Some(canonical_warm_value) + ); + Ok(()) +} + +#[tokio::test] +async fn conflicting_duplicate_index_revokes_the_speculative_branch() -> Result<()> { + let address = Address::repeat_byte(0x77); + let slot = U256::from(7); + let canonical_value = U256::from(1); + let mut cache = setup_cache().await?; + install_mock_erc20(&mut cache, address); + cache + .db_mut() + .insert_account_storage(address, slot, canonical_value)?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(SlotWriter { + address, + slot, + value: U256::from(99), + }))?; + let provider = ProviderRef::new("base-flashblocks", 3); + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![preconfirmed_record( + address, + flashblock(provider.clone(), 1, B256::repeat_byte(0xf1)), + )]) + .with_delivery_scope(DeliveryScope::Preconfirmed), + )?; + assert_eq!( + cache.cached_storage_value(address, slot), + Some(U256::from(99)) + ); + + let result = runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![preconfirmed_record( + address, + flashblock(provider, 1, B256::repeat_byte(0xf2)), + )]) + .with_delivery_scope(DeliveryScope::Preconfirmed), + ); + assert!(result.is_err()); + assert!(runtime.active_preconfirmation().is_none()); + assert_eq!( + cache.cached_storage_value(address, slot), + Some(canonical_value) + ); + Ok(()) +} diff --git a/tests/read_set_warmup.rs b/tests/read_set_warmup.rs new file mode 100644 index 0000000..a0fdaec --- /dev/null +++ b/tests/read_set_warmup.rs @@ -0,0 +1,166 @@ +//! Acceptance tests for cache-owned execution read-set warming and hydration. + +use std::sync::Arc; + +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_provider::{RootProvider, network::AnyNetwork}; +use alloy_rpc_client::RpcClient; +use alloy_rpc_types_eth::TransactionRequest; +use alloy_transport::mock::Asserter; +use evm_fork_cache::cache::EvmCache; +use evm_fork_cache::{ + AccountProof, ReadSetWarmupBatch, ReadSetWarmupCall, ReadSetWarmupConfig, + ReadSetWarmupStrategy, StorageAccessList, +}; +use revm::state::{AccountInfo, Bytecode}; + +async fn cache() -> EvmCache { + let provider = RootProvider::::new(RpcClient::mocked(Asserter::new())); + EvmCache::new(Arc::new(provider)).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn cache_owned_warmup_discovers_filters_and_loads_slots() { + let mut cache = cache().await; + let target = Address::repeat_byte(0x51); + let unrelated = Address::repeat_byte(0x52); + let slot = U256::from(7); + cache.set_access_list_fetcher(Arc::new(move |requests, _block| { + assert_eq!(requests.len(), 1); + let mut access = StorageAccessList::default(); + access.accounts.extend([target, unrelated]); + access.slots.extend([(target, slot), (unrelated, slot)]); + vec![Ok(access)] + })); + cache.set_storage_batch_fetcher(Arc::new(move |requests, _block| { + assert_eq!(requests, vec![(target, slot)]); + vec![(target, slot, Ok(U256::from(99)))] + })); + + let report = cache.prewarm_read_sets( + ReadSetWarmupBatch { + known_slots: Vec::new(), + calls: vec![ReadSetWarmupCall { + tx: TransactionRequest::default().to(target), + expected_slots: Some(32), + restrict_to: Some(vec![target]), + }], + }, + ReadSetWarmupConfig { + strategy: ReadSetWarmupStrategy::AccessList, + ..Default::default() + }, + ); + + assert!(report.used_access_lists); + assert_eq!(report.access_list_successes, 1); + assert_eq!( + report.discovered_access.accounts, + [target].into_iter().collect() + ); + assert_eq!( + report.discovered_access.slots, + [(target, slot)].into_iter().collect() + ); + assert_eq!(report.discovered.loaded, 1); + assert_eq!( + cache.cached_storage_value(target, slot), + Some(U256::from(99)) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn provider_backed_cache_installs_access_list_discovery() { + assert!(cache().await.access_list_fetcher().is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exact_read_set_hydration_refreshes_accounts_code_and_storage_together() { + let mut cache = cache().await; + let target = Address::repeat_byte(0x61); + let slot = U256::from(8); + let code = Bytecode::new_raw(Bytes::from_static(&[0x00])); + let code_hash = code.hash_slow(); + cache.db_mut().insert_account_info( + target, + AccountInfo { + balance: U256::from(1), + nonce: 2, + code_hash, + code: Some(code), + account_id: None, + }, + ); + cache + .insert_storage_slot(target, slot, U256::from(3)) + .expect("seed storage"); + cache.set_account_proof_fetcher(Arc::new(move |requests, _block| { + assert_eq!(requests, vec![(target, vec![slot])]); + vec![( + target, + Ok(AccountProof { + storage_hash: B256::repeat_byte(0x62), + balance: U256::from(10), + nonce: 11, + code_hash, + slots: vec![(slot, U256::from(12))], + }), + )] + })); + let required = StorageAccessList { + accounts: [target].into_iter().collect(), + code_hashes: [code_hash].into_iter().collect(), + slots: [(target, slot)].into_iter().collect(), + ..Default::default() + }; + + let report = cache.hydrate_read_set(&required); + + assert!(report.is_complete(), "{report:?}"); + assert_eq!(report.accounts_refreshed, 1); + assert_eq!(report.slots_refreshed, 1); + assert_eq!( + cache.cached_storage_value(target, slot), + Some(U256::from(12)) + ); + assert!(cache.snapshot().missing_read_set(&required).is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exact_read_set_hydration_rejects_code_layout_changes() { + let mut cache = cache().await; + let target = Address::repeat_byte(0x71); + let code = Bytecode::new_raw(Bytes::from_static(&[0x00])); + let code_hash = code.hash_slow(); + cache.db_mut().insert_account_info( + target, + AccountInfo { + code_hash, + code: Some(code), + ..Default::default() + }, + ); + let changed = B256::repeat_byte(0x72); + cache.set_account_proof_fetcher(Arc::new(move |_, _| { + vec![( + target, + Ok(AccountProof { + storage_hash: B256::ZERO, + balance: U256::ZERO, + nonce: 1, + code_hash: changed, + slots: Vec::new(), + }), + )] + })); + let required = StorageAccessList { + accounts: [target].into_iter().collect(), + code_hashes: [code_hash].into_iter().collect(), + ..Default::default() + }; + + let report = cache.hydrate_read_set(&required); + + assert!(!report.is_complete()); + assert_eq!(report.code_changes, vec![(target, code_hash, changed)]); +} diff --git a/tests/snapshot_overlay.rs b/tests/snapshot_overlay.rs index d0e8e04..ed76935 100644 --- a/tests/snapshot_overlay.rs +++ b/tests/snapshot_overlay.rs @@ -13,15 +13,15 @@ mod common; use std::sync::Arc; -use alloy_primitives::{Address, Bytes, U256, keccak256}; +use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use alloy_sol_types::{SolCall, SolValue}; use anyhow::{Result, anyhow}; use revm::context::result::ExecutionResult; use revm::database_interface::Database; use common::{ - MOCK_ERC20_BALANCE_SLOT, MockERC20, install_default_account, install_mock_erc20, setup_cache, - transfer, + MOCK_ERC20_BALANCE_SLOT, MockERC20, install_default_account, install_mock_erc20, + mock_erc20_runtime, setup_cache, transfer, }; use evm_fork_cache::cache::{EvmOverlay, EvmSnapshot}; @@ -178,6 +178,68 @@ async fn overlay_reads_reflect_snapshot_state() -> Result<()> { Ok(()) } +/// An offline overlay must make an unresolved storage read observable instead +/// of silently treating its ZERO fallback as authoritative state. Readiness +/// gates use this signal to reject an incompletely warmed speculative quote. +#[tokio::test(flavor = "multi_thread")] +async fn offline_overlay_reports_missing_storage_and_reset_clears_it() -> Result<()> { + let mut cache = setup_cache().await?; + let contract = Address::repeat_byte(0x45); + let slot = U256::from(9); + let snapshot = cache.snapshot(); + let mut overlay = EvmOverlay::new(snapshot, None); + + assert_eq!(overlay.storage(contract, slot)?, U256::ZERO); + assert_eq!( + overlay.missing_state().storage, + [(contract, slot)].into_iter().collect() + ); + assert!(!overlay.missing_state().is_empty()); + + overlay.reset(); + assert!(overlay.missing_state().is_empty()); + Ok(()) +} + +/// A missing account header is distinct from an account the snapshot already +/// knows does not exist. Only the unresolved former case makes an offline +/// simulation incomplete. +#[tokio::test(flavor = "multi_thread")] +async fn offline_overlay_reports_unresolved_account_headers() -> Result<()> { + let mut cache = setup_cache().await?; + let unresolved = Address::repeat_byte(0x49); + let snapshot = cache.snapshot(); + let mut overlay = EvmOverlay::new(snapshot, None); + + assert!(overlay.basic(unresolved)?.is_none()); + assert_eq!( + overlay.missing_state().accounts, + [unresolved].into_iter().collect() + ); + let missing = overlay.missing_state().as_read_set(); + assert!(missing.accounts.contains(&unresolved)); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn snapshot_reports_its_complete_resident_read_set() -> Result<()> { + let mut cache = setup_cache().await?; + let token = Address::repeat_byte(0x4a); + install_mock_erc20(&mut cache, token); + cache.insert_storage_slot(token, U256::from(7), U256::from(9))?; + + let resident = cache.snapshot().resident_read_set(); + + assert!(resident.accounts.contains(&token)); + assert!( + resident + .code_hashes + .contains(&mock_erc20_runtime().hash_slow()) + ); + assert!(resident.slots.contains(&(token, U256::from(7)))); + Ok(()) +} + /// A call-scoped code override must affect nested execution without leaking /// into the reusable overlay. V3 quoters need this to neutralize the output /// token transfer that precedes their intentional quote-data revert when a @@ -305,3 +367,24 @@ async fn snapshot_basic_returns_none_for_notexisting_account() -> Result<()> { ); Ok(()) } + +#[tokio::test] +async fn snapshots_retain_resident_block_hash_dependencies_offline() -> Result<()> { + let mut cache = setup_cache().await?; + let number = 42_u64; + let hash = B256::repeat_byte(0x42); + cache + .db_mut() + .cache + .block_hashes + .insert(U256::from(number), hash); + + let snapshot = cache.snapshot(); + assert!(snapshot.resident_read_set().block_numbers.contains(&number)); + let mut overlay = EvmOverlay::new(snapshot, None); + assert_eq!(overlay.block_hash(number)?, hash); + + let mut deep = EvmOverlay::new(cache.snapshot_deep_clone(), None); + assert_eq!(deep.block_hash(number)?, hash); + Ok(()) +} From 010caa7b82ca60e12f6f9d87090b5279d14238ca Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 4 Aug 2026 21:52:03 +0100 Subject: [PATCH 3/8] Harden alpha.2 read-set hydration --- .github/workflows/ci.yml | 40 ++- CHANGELOG.md | 14 +- README.md | 18 +- RELEASING.md | 4 + SECURITY.md | 6 + docs/KNOWN_ISSUES.md | 25 +- scripts/check-authoring-hygiene.sh | 38 +++ src/cache/mod.rs | 12 +- src/cache/read_set.rs | 269 ++++++++++++++--- src/lib.rs | 8 +- tests/public_release_surface.rs | 36 +++ tests/read_set_warmup.rs | 462 +++++++++++++++++++++++++++-- 12 files changed, 840 insertions(+), 92 deletions(-) create mode 100644 scripts/check-authoring-hygiene.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c13a3c7..6c89e3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,16 +7,32 @@ on: env: CARGO_TERM_COLOR: always + TRANSPORT_REF: 5d012b8b848cd061c9aa909a17597cd4c303f4b7 permissions: contents: read +defaults: + run: + working-directory: evm-fork-cache + jobs: check: name: release gates runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Check out cache + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + path: evm-fork-cache + fetch-depth: 0 + + - name: Check out exact transport candidate + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: KaiCode2/alloy-transport-balancer + ref: ${{ env.TRANSPORT_REF }} + path: alloy-transport-balancer - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable @@ -26,10 +42,18 @@ jobs: - name: Cache cargo artifacts uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: evm-fork-cache -> target + + - name: Authoring hygiene + run: bash scripts/check-authoring-hygiene.sh - name: Formatting run: cargo fmt --all --check + - name: Diff hygiene + run: git diff --check + - name: Clippy (all features) run: cargo clippy --locked --all-targets --all-features --no-deps -- -D warnings @@ -77,7 +101,17 @@ jobs: name: msrv (1.90) runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Check out cache + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + path: evm-fork-cache + + - name: Check out exact transport candidate + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: KaiCode2/alloy-transport-balancer + ref: ${{ env.TRANSPORT_REF }} + path: alloy-transport-balancer - name: Install Rust 1.90 (declared MSRV) uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable @@ -86,6 +120,8 @@ jobs: - name: Cache cargo artifacts uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: evm-fork-cache -> target # The published library must build on the MSRV advertised in Cargo.toml. # Scoped to --lib so the dev-only example/bench toolchain requirements diff --git a/CHANGELOG.md b/CHANGELOG.md index 731da32..c4e4d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,10 @@ surface freezes at 1.0. ### Added -- Added cache-owned execution read-set discovery and exact-block hydration for - account, code, storage, and block-hash dependencies, including missing-state - provenance and provider-read instrumentation. +- Added cache-owned execution read-set discovery and exact-block proof + hydration for account headers and storage, code-identity validation against + resident bytecode, and canonical block-hash residency checks, including + typed missing-state provenance and provider-read instrumentation. - Added `AlloySubscriber::establish_flashblocks_preflight` to verify a pinned Base or OP chain and establish its chain-specific Flashblocks surface while retaining optional provider capability evidence. @@ -41,6 +42,13 @@ surface freezes at 1.0. - Raised the minimum supported Rust version to 1.90 and updated the locked `ruint` dependency to the release that resolves `RUSTSEC-2026-0220`. +- Read-set discovery now rejects a selected-but-unavailable access-list fetcher + and any callback result-count mismatch before partially warming the declared + slots. Exact hydration exposes structured failure causes, rejects duplicate + or unrequested proof slots, and fails closed when runtime bytecode or + historical block hashes are not already resident. +- Alpha.2 CI now checks out the transport candidate by exact commit and applies + a release-delta authoring-hygiene gate before the locked release matrix. - Preconfirmed identity is now a non-zero, provider-generation-scoped content commitment over the cumulative preview, with any real provider-reported partial hash retained separately. Pending logs correlate by block number and diff --git a/README.md b/README.md index 8fa156e..4529115 100644 --- a/README.md +++ b/README.md @@ -394,10 +394,20 @@ successful probe alone is not liveness. `StorageAccessList` covers accounts, runtime-code identities, storage slots, and `BLOCKHASH` dependencies. Provider-backed caches can discover large unknown call read sets with exact-block `eth_createAccessList` probes through -`EvmCache::prewarm_read_sets`; small or known sets continue through the ordinary -bulk loader. `EvmCache::hydrate_read_set` refreshes account headers and storage -together with exact-pin `eth_getProof`, reports incomplete proofs, and rejects a -runtime-code hash change instead of reusing slot identifiers across layouts. +`EvmCache::prewarm_read_sets`; small or declared sets continue through the +ordinary bulk loader. Required discovery fails with a typed error when no +callback is installed or when it violates the one-result-per-call contract; +individual provider failures remain indexed in the successful batch report. + +`EvmCache::hydrate_read_set` refreshes account headers and requested storage +together with exact-pin `eth_getProof`, reports incomplete or malformed proof +responses with typed causes, and rejects a runtime-code hash change instead of +reusing slot identifiers across layouts. Because `eth_getProof` returns a code +hash rather than bytecode, deployed runtime code must already be resident before +exact hydration. Historical block hashes must likewise already be retained in +the canonical cache. Absent bytecode or block hashes remain in `missing_after`, +so `ReadSetHydrationReport::is_complete` stays false and callers can reject the +candidate without an implicit hot-path read. Snapshots expose `resident_read_set` and `missing_read_set`, retain cached block hashes, and RPC-disconnected overlays return a precise `MissingState`. Consumers diff --git a/RELEASING.md b/RELEASING.md index e211a01..9885c69 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -25,6 +25,7 @@ cargo clippy --locked --all-targets --no-default-features --features reactive-po cargo test --locked --no-default-features --features reactive-polling cargo +1.90.0 check --locked --lib cargo bench --no-run --all-features --locked +bash scripts/check-authoring-hygiene.sh bash scripts/check-security-exceptions.sh cargo audit --ignore RUSTSEC-2025-0055 cargo package --locked @@ -43,6 +44,9 @@ Confirm every third-party `uses:` entry remains pinned to the officially verified full commit recorded in `SECURITY.md`, not a mutable tag or branch. The stable and MSRV jobs must use the same pinned `dtolnay/rust-toolchain` action with explicit `toolchain: stable` and `toolchain: 1.90.0` inputs. +Confirm the workflow's transport checkout still names the reviewed exact +candidate commit recorded in `SECURITY.md`; a moving branch is not an acceptable +substitute. Inspect `cargo package --list --locked` and confirm that secrets, local databases, planning/spec documents, and build output are excluded while consumer diff --git a/SECURITY.md b/SECURITY.md index bec318b..79b1ac6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -105,3 +105,9 @@ comments retain the reviewed human-readable upstream ref: The stable and MSRV jobs use the same reviewed toolchain-action commit and pass their requested toolchain explicitly. Updating any action requires verifying the new upstream ref and full commit before changing the pin. + +Sibling development dependencies are also immutable in CI. The alpha.2 cache +workflow checks out `alloy-transport-balancer` at exact commit +`5d012b8b848cd061c9aa909a17597cd4c303f4b7`, matching the first candidate in +the documented publish order. Changing that revision requires rerunning the +cache's complete locked release matrix. diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 66b519e..47f51d8 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -156,19 +156,18 @@ surface was moved out of this crate. token emitting such a value would corrupt the reconstructed delta. Real ERC-20 supplies are far below 2^255, so this is unreachable for honest tokens; a malicious token can misreport balances by other means regardless. -- **[Hardened in 0.2.0] `BLOCKHASH` resolves to ZERO in ext-db-less overlays — - and the freshness validator now fails closed on it.** Snapshots do not track - block hashes (the live cache does not track them either), so an `EvmOverlay` - built without an `ext_db` returns `B256::ZERO` for in-lookback-range - `BLOCKHASH` reads. Since 0.2.0 the freshness pipeline records such reads - (`EvmOverlay::blockhash_zero_fallback`) and reports the batch - `Validation::Unverified` — on the optimistic pass **and** on corrected - re-runs — instead of silently confirming a result whose control flow may - depend on the real hash. Out-of-range reads return the spec-mandated ZERO - without a database call and are deliberately not flagged (they are correct - on-chain too). Direct, non-validator simulations over ext-db-less overlays - still observe ZERO; supply an `ext_db` or snapshot-provided hashes when - `BLOCKHASH` accuracy matters to such a sim. +- **[Hardened in 0.2.0; canonical residency added in 0.4.0-alpha.2] + `BLOCKHASH` fails closed when its canonical value is absent.** The live cache + and its snapshots retain block hashes that have been loaded from canonical + state, and `hydrate_read_set` reports any required non-resident hash through + `missing_after`; it does not issue a separate hash fetch. An `EvmOverlay` + without a resident hash or `ext_db` still returns `B256::ZERO` for an + in-lookback-range read, but records that fallback. The freshness pipeline + therefore reports the batch `Validation::Unverified` on the optimistic pass + and on corrected re-runs instead of confirming control flow that may depend + on the real hash. Out-of-range reads return the spec-mandated ZERO without a + database call and are deliberately not flagged. Warm the canonical hash + before publishing an offline snapshot whenever `BLOCKHASH` accuracy matters. - **[Closed in 0.2.0] `EventPipeline::derived_slots` is bounded.** The event-derived `(address, slot)` set is now a block-horizon ring bounded to `ReorgConfig::depth` (mirroring the `touched` ring), so steady-state diff --git a/scripts/check-authoring-hygiene.sh b/scripts/check-authoring-hygiene.sh new file mode 100644 index 0000000..0e07e3d --- /dev/null +++ b/scripts/check-authoring-hygiene.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +release_base="1e5bbe854e781c8c7a161bf158c2b645a2fe75c0" +terms=( + "$(printf '%b' '\x63\x6f\x64\x65\x78')" + "$(printf '%b' '\x63\x68\x61\x74\x67\x70\x74')" + "$(printf '%b' '\x6f\x70\x65\x6e\x61\x69')" + "$(printf '%b' '\x63\x6c\x61\x75\x64\x65')" + "$(printf '%b' '\x63\x6f\x70\x69\x6c\x6f\x74')" + "$(printf '%b' '\x67\x65\x6d\x69\x6e\x69')" + "$(printf '%b' '\x77\x69\x6e\x64\x73\x75\x72\x66')" + "$(printf '%b' '\x61\x69\x64\x65\x72')" + "$(printf '%b' '\x64\x65\x76\x69\x6e')" + "$(printf '%b' '\x63\x6f\x64\x65\x69\x75\x6d')" +) +pattern="$(IFS='|'; printf '%s' "${terms[*]}")" + +if ! git cat-file -e "${release_base}^{commit}"; then + echo "release-base commit is unavailable; fetch complete history" >&2 + exit 1 +fi + +if git grep -IinE "${pattern}" -- . ':!scripts/check-authoring-hygiene.sh'; then + echo "tracked release content contains a prohibited attribution term" >&2 + exit 1 +fi + +if git log "${release_base}..HEAD" --format='%H%n%s%n%b' | grep -inE "${pattern}"; then + echo "release-delta commit metadata contains a prohibited attribution term" >&2 + exit 1 +fi + +branch="$(git branch --show-current)" +if printf '%s\n' "${branch}" | grep -inE "${pattern}"; then + echo "current branch contains a prohibited attribution term" >&2 + exit 1 +fi diff --git a/src/cache/mod.rs b/src/cache/mod.rs index c0d2bf8..9f32f02 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -36,8 +36,9 @@ pub use durable_checkpoint::{ pub use metadata::{CacheConfig, ImmutableDataCache}; pub use overlay::{EvmOverlay, MissingState}; pub use read_set::{ - AccessListFetchFn, ReadSetHydrationReport, ReadSetWarmupBatch, ReadSetWarmupCall, - ReadSetWarmupConfig, ReadSetWarmupReport, ReadSetWarmupStrategy, + AccessListFetchFn, ReadSetHydrationFailure, ReadSetHydrationReport, ReadSetWarmupBatch, + ReadSetWarmupCall, ReadSetWarmupConfig, ReadSetWarmupError, ReadSetWarmupReport, + ReadSetWarmupStrategy, }; pub use slot_observations::SlotObservationTracker; pub use snapshot::EvmSnapshot; @@ -163,9 +164,10 @@ pub struct AccountProof { /// /// **Contract:** an implementation returns at most one result per requested /// address. An address present with `Ok(..)` succeeded; present with `Err(..)` -/// failed; omitted entirely means the fetcher produced no result for it. Callers -/// derive their per-address outcome from whether the address appears and, if so, -/// whether it is `Ok`/`Err`. +/// failed; omitted entirely means the fetcher produced no result for it. A +/// successful proof contains exactly one `(slot, value)` pair for every +/// requested key and no unrequested keys. Callers derive their per-address and +/// per-slot outcomes from that shape and fail closed when it is violated. pub type AccountProofFetchFn = Arc< dyn Fn(Vec<(Address, Vec)>, BlockId) -> Vec<(Address, StorageFetchResult)> + Send diff --git a/src/cache/read_set.rs b/src/cache/read_set.rs index d128e46..0949ed8 100644 --- a/src/cache/read_set.rs +++ b/src/cache/read_set.rs @@ -9,7 +9,82 @@ use alloy_rpc_types_eth::TransactionRequest; use super::{EvmCache, PrewarmReport}; use crate::access_set::StorageAccessList; -use crate::errors::AccessListError; +use crate::errors::{AccessListError, StorageFetchError}; + +/// One exact-hydration failure, with enough structure for callers to decide +/// whether to retry, re-warm, or reject a candidate. +#[derive(Clone, Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ReadSetHydrationFailure { + /// The cache has no account-proof callback installed. + #[error("no account proof fetcher is installed for {address}")] + ProofFetcherUnavailable { + /// Account that could not be refreshed. + address: Address, + }, + /// The callback returned no result for a requested account. + #[error("account proof fetcher omitted requested address {address}")] + ProofResultMissing { + /// Requested account omitted by the callback. + address: Address, + }, + /// The callback returned more than one result for a requested account. + #[error("account proof fetcher returned duplicate results for {address}")] + ProofResultDuplicate { + /// Requested account with ambiguous results. + address: Address, + }, + /// The callback returned a result for an account that was not requested. + #[error("account proof fetcher returned unexpected address {address}")] + ProofResultUnexpected { + /// Unrequested account returned by the callback. + address: Address, + }, + /// A successful account proof returned one requested storage slot more + /// than once, making its value ambiguous. + #[error("account proof for {address} returned duplicate storage slot {slot}")] + StorageSlotDuplicate { + /// Account whose proof contained the duplicate slot. + address: Address, + /// Requested slot returned more than once. + slot: U256, + }, + /// A successful account proof returned a storage slot that was not + /// requested. + #[error("account proof for {address} returned unexpected storage slot {slot}")] + StorageSlotUnexpected { + /// Account whose proof contained the unrequested slot. + address: Address, + /// Unrequested slot returned by the callback. + slot: U256, + }, + /// The provider or custom callback failed for one requested account. + #[error("account proof fetch failed for {address}: {source}")] + ProofFetch { + /// Account whose proof failed. + address: Address, + /// Typed provider/callback failure. + #[source] + source: StorageFetchError, + }, + /// A deployed account's runtime code is not resident, so its code identity + /// cannot be validated from a hash-only proof. + #[error("runtime code {code_hash} is not resident for deployed account {address}")] + RuntimeCodeUnavailable { + /// Deployed account requiring runtime code. + address: Address, + /// Code hash reported by the exact-block account proof. + code_hash: alloy_primitives::B256, + }, + /// A successful account proof omitted one requested storage slot. + #[error("account proof for {address} omitted requested storage slot {slot}")] + StorageSlotMissing { + /// Account whose proof was incomplete. + address: Address, + /// Requested slot omitted from the proof result. + slot: U256, + }, +} /// Exact-block hydration result for one learned execution read set. #[derive(Clone, Debug)] @@ -20,8 +95,8 @@ pub struct ReadSetHydrationReport { pub accounts_refreshed: usize, /// Storage slots refreshed from proofs. pub slots_refreshed: usize, - /// Per-account provider or proof-shape failures. - pub account_failures: Vec<(Address, String)>, + /// Typed provider, callback, code-residency, or proof-shape failures. + pub failures: Vec, /// Runtime-code identity changes that invalidate the learned layout. pub code_changes: Vec<(Address, alloy_primitives::B256, alloy_primitives::B256)>, /// Required reads still unavailable after hydration. @@ -32,13 +107,15 @@ impl ReadSetHydrationReport { /// Whether every requested dependency is resident and every code identity /// still matches the learned layout. pub fn is_complete(&self) -> bool { - self.account_failures.is_empty() - && self.code_changes.is_empty() - && self.missing_after.is_empty() + self.failures.is_empty() && self.code_changes.is_empty() && self.missing_after.is_empty() } } /// Callback for deriving calls' read sets via `eth_createAccessList`. +/// +/// The returned vector must contain exactly one result for each request, in +/// request order. [`EvmCache::prewarm_read_sets`] rejects the whole discovery +/// batch when that cardinality contract is violated. pub type AccessListFetchFn = Arc< dyn Fn( Vec, @@ -48,6 +125,27 @@ pub type AccessListFetchFn = Arc< + Sync, >; +/// A cache-owned read-set warmup could not honor its selected discovery policy. +#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum ReadSetWarmupError { + /// Access-list discovery was selected, but the cache has no discovery + /// callback installed. + #[error("access-list discovery was required for {calls} call(s), but no fetcher is installed")] + AccessListFetcherUnavailable { + /// Number of calls that could not be discovered. + calls: usize, + }, + /// The callback violated the one-result-per-request contract. + #[error("access-list fetcher returned {actual} result(s) for {expected} request(s)")] + AccessListResultCountMismatch { + /// Number of access-list requests issued. + expected: usize, + /// Number of callback results returned. + actual: usize, + }, +} + /// One call whose storage read set may be remotely discovered. #[derive(Clone, Debug, Default)] pub struct ReadSetWarmupCall { @@ -74,7 +172,7 @@ pub enum ReadSetWarmupStrategy { /// Use access-list discovery only when the call hints justify its round trip. #[default] Auto, - /// Keep discovery local to the later simulation path. + /// Warm declared slots only; leave every call for later local simulation. LocalOnly, /// Attempt access-list discovery for every declared call. AccessList, @@ -107,7 +205,10 @@ impl ReadSetWarmupConfig { ReadSetWarmupStrategy::LocalOnly => false, ReadSetWarmupStrategy::AccessList => !calls.is_empty(), ReadSetWarmupStrategy::Auto => { - let expected: usize = calls.iter().filter_map(|call| call.expected_slots).sum(); + let expected = calls + .iter() + .filter_map(|call| call.expected_slots) + .fold(0usize, usize::saturating_add); let unhinted = calls .iter() .filter(|call| call.expected_slots.is_none()) @@ -126,7 +227,7 @@ pub struct ReadSetWarmupReport { pub known: PrewarmReport, /// Whether remote access-list discovery was attempted. pub used_access_lists: bool, - /// Calls skipped by policy or an unavailable fetcher. + /// Calls skipped because the selected policy did not request discovery. pub skipped_calls: usize, /// Successful access-list probes. pub access_list_successes: usize, @@ -151,11 +252,43 @@ impl EvmCache { /// Warm known slots and, when selected by policy, discover and bulk-load /// unknown call read sets through cache-owned provider plumbing. + /// + /// An access-list callback is mandatory when [`ReadSetWarmupStrategy::AccessList`] + /// is selected, or when [`ReadSetWarmupStrategy::Auto`] crosses its configured + /// threshold. A callback result remains a per-call success or failure, but + /// the callback itself must return exactly one result per request. + /// + /// # Errors + /// + /// Returns [`ReadSetWarmupError::AccessListFetcherUnavailable`] when remote + /// discovery was selected without an installed callback, or + /// [`ReadSetWarmupError::AccessListResultCountMismatch`] when the callback + /// violates the one-result-per-request contract. pub fn prewarm_read_sets( &mut self, batch: ReadSetWarmupBatch, config: ReadSetWarmupConfig, - ) -> ReadSetWarmupReport { + ) -> Result { + let discovery_results = + if batch.calls.is_empty() || !config.should_use_access_lists(&batch.calls) { + None + } else { + let Some(fetcher) = self.access_list_fetcher.clone() else { + return Err(ReadSetWarmupError::AccessListFetcherUnavailable { + calls: batch.calls.len(), + }); + }; + let requests = batch.calls.iter().map(|call| call.tx.clone()).collect(); + let results = fetcher(requests, self.block); + if results.len() != batch.calls.len() { + return Err(ReadSetWarmupError::AccessListResultCountMismatch { + expected: batch.calls.len(), + actual: results.len(), + }); + } + Some(results) + }; + let known = if batch.known_slots.is_empty() { PrewarmReport::default() } else { @@ -166,28 +299,20 @@ impl EvmCache { ..Default::default() }; if batch.calls.is_empty() { - return report; - } - if !config.should_use_access_lists(&batch.calls) { - report.skipped_calls = batch.calls.len(); - return report; + return Ok(report); } - let Some(fetcher) = self.access_list_fetcher.clone() else { + let Some(results) = discovery_results else { report.skipped_calls = batch.calls.len(); - return report; + return Ok(report); }; report.used_access_lists = true; - let requests = batch.calls.iter().map(|call| call.tx.clone()).collect(); - let mut results = fetcher(requests, self.block).into_iter(); + let mut results = results.into_iter(); let mut discovered = StorageAccessList::default(); for (index, call) in batch.calls.iter().enumerate() { - let result = results.next().unwrap_or_else(|| { - Err(AccessListError::query( - "eth_createAccessList", - "access-list fetcher omitted a result", - )) - }); + let result = results + .next() + .expect("access-list result count was checked above"); match result { Ok(mut access) => { if let Some(restrict_to) = &call.restrict_to { @@ -208,7 +333,7 @@ impl EvmCache { if !slots.is_empty() { report.discovered = self.prewarm_slots(&slots); } - report + Ok(report) } /// Refresh a learned execution read set at this cache's exact block pin. @@ -219,6 +344,13 @@ impl EvmCache { /// proof reports the same code hash; a changed hash is surfaced explicitly /// so an AMM manifest can be invalidated instead of simulating against a new /// layout with stale slot identifiers. + /// + /// Hash-only proofs cannot supply runtime bytecode. Code required by the + /// read set must therefore already be resident, and its identity must match + /// the proof. Historical `BLOCKHASH` values are likewise never fetched by + /// this method: they must already be present in the canonical cache. Any + /// missing code, slot, account, or block hash keeps the returned report + /// incomplete. pub fn hydrate_read_set(&mut self, required: &StorageAccessList) -> ReadSetHydrationReport { let block = self.block; let mut requests: BTreeMap> = BTreeMap::new(); @@ -237,7 +369,7 @@ impl EvmCache { block, accounts_refreshed: 0, slots_refreshed: 0, - account_failures: Vec::new(), + failures: Vec::new(), code_changes: Vec::new(), missing_after: required.clone(), }; @@ -246,11 +378,11 @@ impl EvmCache { return report; } let Some(fetcher) = self.account_proof_fetcher.clone() else { - report.account_failures.extend( + report.failures.extend( requests .keys() .copied() - .map(|address| (address, "no account proof fetcher installed".to_owned())), + .map(|address| ReadSetHydrationFailure::ProofFetcherUnavailable { address }), ); return report; }; @@ -259,21 +391,45 @@ impl EvmCache { .iter() .map(|(address, slots)| (*address, slots.clone())) .collect(); - let fetched: HashMap<_, _> = fetcher(requested, block).into_iter().collect(); + let mut fetched = HashMap::new(); + let mut duplicate_addresses = HashSet::new(); + for (address, result) in fetcher(requested, block) { + if !requests.contains_key(&address) { + report + .failures + .push(ReadSetHydrationFailure::ProofResultUnexpected { address }); + continue; + } + if duplicate_addresses.contains(&address) || fetched.contains_key(&address) { + if duplicate_addresses.insert(address) { + report + .failures + .push(ReadSetHydrationFailure::ProofResultDuplicate { address }); + } + fetched.remove(&address); + continue; + } + fetched.insert(address, result); + } let mut fresh_slots = Vec::new(); for (address, expected_slots) in requests { + if duplicate_addresses.contains(&address) { + continue; + } let Some(result) = fetched.get(&address) else { - report.account_failures.push(( - address, - "account proof fetcher omitted the requested address".to_owned(), - )); + report + .failures + .push(ReadSetHydrationFailure::ProofResultMissing { address }); continue; }; let proof = match result { Ok(proof) => proof, Err(error) => { - report.account_failures.push((address, error.to_string())); + report.failures.push(ReadSetHydrationFailure::ProofFetch { + address, + source: error.clone(), + }); continue; } }; @@ -291,10 +447,12 @@ impl EvmCache { && proof.code_hash != alloy_primitives::B256::ZERO && proof.code_hash != revm::primitives::KECCAK_EMPTY { - report.account_failures.push(( - address, - "runtime code was not resident for a deployed account".to_owned(), - )); + report + .failures + .push(ReadSetHydrationFailure::RuntimeCodeUnavailable { + address, + code_hash: proof.code_hash, + }); continue; } @@ -305,14 +463,37 @@ impl EvmCache { self.write_account_info_through(address, info); report.accounts_refreshed += 1; - let by_slot: HashMap<_, _> = proof.slots.iter().copied().collect(); + let expected_slot_set: HashSet<_> = expected_slots.iter().copied().collect(); + let mut by_slot = HashMap::new(); + let mut duplicate_slots = HashSet::new(); + for (slot, value) in proof.slots.iter().copied() { + if !expected_slot_set.contains(&slot) { + report + .failures + .push(ReadSetHydrationFailure::StorageSlotUnexpected { address, slot }); + continue; + } + if duplicate_slots.contains(&slot) || by_slot.contains_key(&slot) { + if duplicate_slots.insert(slot) { + report + .failures + .push(ReadSetHydrationFailure::StorageSlotDuplicate { address, slot }); + } + by_slot.remove(&slot); + continue; + } + by_slot.insert(slot, value); + } let mut complete_slots = true; for slot in expected_slots { + if duplicate_slots.contains(&slot) { + complete_slots = false; + continue; + } let Some(value) = by_slot.get(&slot).copied() else { - report.account_failures.push(( - address, - format!("account proof omitted requested storage slot {slot}"), - )); + report + .failures + .push(ReadSetHydrationFailure::StorageSlotMissing { address, slot }); complete_slots = false; continue; }; diff --git a/src/lib.rs b/src/lib.rs index 5666841..f47c78a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -181,10 +181,10 @@ pub use cache::{ CodeVerifyReport, DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES, DurableCheckpointBlock, DurableCheckpointError, DurableCheckpointIdentity, DurableCheckpointMetadata, DurableCheckpointStore, EvmCache, EvmCacheBuilder, EvmOverlay, EvmSnapshot, - LoadedDurableCheckpoint, PrewarmReport, ReadSetHydrationReport, ReadSetWarmupBatch, - ReadSetWarmupCall, ReadSetWarmupConfig, ReadSetWarmupReport, ReadSetWarmupStrategy, - StorageBatchConfig, StorageFetchStrategy, TxConfig, account_proof_fetcher, - point_read_storage_fetcher, provider_storage_fetcher, + LoadedDurableCheckpoint, PrewarmReport, ReadSetHydrationFailure, ReadSetHydrationReport, + ReadSetWarmupBatch, ReadSetWarmupCall, ReadSetWarmupConfig, ReadSetWarmupError, + ReadSetWarmupReport, ReadSetWarmupStrategy, StorageBatchConfig, StorageFetchStrategy, TxConfig, + account_proof_fetcher, point_read_storage_fetcher, provider_storage_fetcher, }; #[cfg(feature = "reactive")] pub use cold_start::{ diff --git a/tests/public_release_surface.rs b/tests/public_release_surface.rs index 5be4f29..b74b032 100644 --- a/tests/public_release_surface.rs +++ b/tests/public_release_surface.rs @@ -299,3 +299,39 @@ fn library_error_surface_is_typed_not_anyhow() { } } } + +#[test] +fn execution_read_set_surface_is_typed_and_fail_closed() { + let read_set = read("src/cache/read_set.rs"); + let exports = read("src/lib.rs"); + + for required in [ + "pub enum ReadSetWarmupError", + "AccessListFetcherUnavailable", + "AccessListResultCountMismatch", + "pub enum ReadSetHydrationFailure", + "ProofResultMissing", + "ProofResultDuplicate", + "ProofResultUnexpected", + "StorageSlotDuplicate", + "StorageSlotUnexpected", + "RuntimeCodeUnavailable", + "pub failures: Vec", + ] { + assert!( + read_set.contains(required), + "read-set API should retain its typed failure contract: {required}" + ); + } + for required in ["ReadSetWarmupError", "ReadSetHydrationFailure"] { + assert!( + exports.contains(required), + "crate root should export the public read-set failure type: {required}" + ); + } + assert!( + !read_set.contains("account_failures: Vec<(Address, String)>") + && !read_set.contains("unwrap_or_else(|| Ok(StorageAccessList::default()))"), + "read-set failures must not regress to strings or fabricated callback results" + ); +} diff --git a/tests/read_set_warmup.rs b/tests/read_set_warmup.rs index a0fdaec..df857d8 100644 --- a/tests/read_set_warmup.rs +++ b/tests/read_set_warmup.rs @@ -1,6 +1,9 @@ //! Acceptance tests for cache-owned execution read-set warming and hydration. -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_provider::{RootProvider, network::AnyNetwork}; @@ -9,9 +12,11 @@ use alloy_rpc_types_eth::TransactionRequest; use alloy_transport::mock::Asserter; use evm_fork_cache::cache::EvmCache; use evm_fork_cache::{ - AccountProof, ReadSetWarmupBatch, ReadSetWarmupCall, ReadSetWarmupConfig, - ReadSetWarmupStrategy, StorageAccessList, + AccountProof, ReadSetHydrationFailure, ReadSetWarmupBatch, ReadSetWarmupCall, + ReadSetWarmupConfig, ReadSetWarmupError, ReadSetWarmupStrategy, StorageAccessList, + StorageFetchError, }; +use revm::primitives::hardfork::SpecId; use revm::state::{AccountInfo, Bytecode}; async fn cache() -> EvmCache { @@ -19,6 +24,427 @@ async fn cache() -> EvmCache { EvmCache::new(Arc::new(provider)).await } +async fn cache_without_fetchers() -> EvmCache { + let base = cache().await; + EvmCache::from_backend( + base.unchecked_backend().clone(), + base.unchecked_blockchain_db().clone(), + base.block(), + base.chain_id(), + None, + None, + SpecId::CANCUN, + ) +} + +#[tokio::test(flavor = "multi_thread")] +async fn required_access_list_discovery_fails_when_no_fetcher_is_installed() { + let mut cache = cache_without_fetchers().await; + let error = cache + .prewarm_read_sets( + ReadSetWarmupBatch { + known_slots: Vec::new(), + calls: vec![ReadSetWarmupCall { + tx: TransactionRequest::default().to(Address::repeat_byte(0x41)), + expected_slots: Some(1), + restrict_to: None, + }], + }, + ReadSetWarmupConfig { + strategy: ReadSetWarmupStrategy::AccessList, + ..Default::default() + }, + ) + .expect_err("required access-list discovery must not silently skip"); + + assert!(matches!( + error, + ReadSetWarmupError::AccessListFetcherUnavailable { calls: 1 } + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn access_list_discovery_rejects_result_count_mismatches() { + for actual in [0, 2] { + let mut cache = cache().await; + cache.set_access_list_fetcher(Arc::new(move |_requests, _block| { + (0..actual) + .map(|_| Ok(StorageAccessList::default())) + .collect() + })); + + let error = cache + .prewarm_read_sets( + ReadSetWarmupBatch { + known_slots: Vec::new(), + calls: vec![ReadSetWarmupCall { + tx: TransactionRequest::default().to(Address::repeat_byte(0x42)), + expected_slots: Some(1), + restrict_to: None, + }], + }, + ReadSetWarmupConfig { + strategy: ReadSetWarmupStrategy::AccessList, + ..Default::default() + }, + ) + .expect_err("fetcher result cardinality is part of the public contract"); + + assert!(matches!( + error, + ReadSetWarmupError::AccessListResultCountMismatch { + expected: 1, + actual: observed, + } if observed == actual + )); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn discovery_contract_errors_do_not_partially_warm_known_slots() { + let mut cache = cache().await; + let target = Address::repeat_byte(0x54); + let slot = U256::from(9); + let fetches = Arc::new(AtomicUsize::new(0)); + let observed_fetches = Arc::clone(&fetches); + cache.set_storage_batch_fetcher(Arc::new(move |requests, _block| { + observed_fetches.fetch_add(1, Ordering::SeqCst); + requests + .into_iter() + .map(|(address, slot)| (address, slot, Ok(U256::from(77)))) + .collect() + })); + cache.set_access_list_fetcher(Arc::new(|_requests, _block| Vec::new())); + + let error = cache + .prewarm_read_sets( + ReadSetWarmupBatch { + known_slots: vec![(target, slot)], + calls: vec![ReadSetWarmupCall { + tx: TransactionRequest::default().to(target), + expected_slots: Some(1), + restrict_to: None, + }], + }, + ReadSetWarmupConfig { + strategy: ReadSetWarmupStrategy::AccessList, + ..Default::default() + }, + ) + .expect_err("a malformed discovery batch must fail before cache mutation"); + + assert!(matches!( + error, + ReadSetWarmupError::AccessListResultCountMismatch { + expected: 1, + actual: 0 + } + )); + assert_eq!(fetches.load(Ordering::SeqCst), 0); + assert_eq!(cache.cached_storage_value(target, slot), None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn automatic_discovery_saturates_extreme_slot_hints() { + let mut cache = cache().await; + cache.set_access_list_fetcher(Arc::new(|requests, _block| { + requests + .into_iter() + .map(|_| Ok(StorageAccessList::default())) + .collect() + })); + + let report = cache + .prewarm_read_sets( + ReadSetWarmupBatch { + known_slots: Vec::new(), + calls: vec![ + ReadSetWarmupCall { + tx: TransactionRequest::default().to(Address::repeat_byte(0x43)), + expected_slots: Some(usize::MAX), + restrict_to: None, + }, + ReadSetWarmupCall { + tx: TransactionRequest::default().to(Address::repeat_byte(0x44)), + expected_slots: Some(1), + restrict_to: None, + }, + ], + }, + ReadSetWarmupConfig::default(), + ) + .expect("automatic access-list discovery"); + + assert!(report.used_access_lists); + assert_eq!(report.access_list_successes, 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exact_hydration_reports_a_typed_missing_proof_fetcher() { + let mut cache = cache_without_fetchers().await; + let target = Address::repeat_byte(0x45); + let required = StorageAccessList { + accounts: [target].into_iter().collect(), + ..Default::default() + }; + + let report = cache.hydrate_read_set(&required); + + assert!(!report.is_complete()); + assert!(matches!( + report.failures.as_slice(), + [ReadSetHydrationFailure::ProofFetcherUnavailable { address }] if *address == target + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exact_hydration_rejects_duplicate_and_unexpected_proof_results() { + let mut cache = cache().await; + let target = Address::repeat_byte(0x46); + let unexpected = Address::repeat_byte(0x47); + cache.set_account_proof_fetcher(Arc::new(move |_requests, _block| { + let proof = AccountProof { + storage_hash: B256::ZERO, + balance: U256::ZERO, + nonce: 0, + code_hash: B256::ZERO, + slots: Vec::new(), + }; + vec![ + (target, Ok(proof.clone())), + (target, Ok(proof.clone())), + (unexpected, Ok(proof)), + ] + })); + let required = StorageAccessList { + accounts: [target].into_iter().collect(), + ..Default::default() + }; + + let report = cache.hydrate_read_set(&required); + + assert!(!report.is_complete()); + assert!(report.failures.iter().any(|failure| matches!( + failure, + ReadSetHydrationFailure::ProofResultDuplicate { address } if *address == target + ))); + assert!(report.failures.iter().any(|failure| matches!( + failure, + ReadSetHydrationFailure::ProofResultUnexpected { address } if *address == unexpected + ))); + assert_eq!(report.accounts_refreshed, 0); + assert!(report.missing_after.accounts.contains(&target)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exact_hydration_rejects_duplicate_and_unexpected_proof_slots() { + let mut cache = cache().await; + let target = Address::repeat_byte(0x55); + let requested = U256::from(1); + let unexpected = U256::from(2); + cache.set_account_proof_fetcher(Arc::new(move |_requests, _block| { + vec![( + target, + Ok(AccountProof { + storage_hash: B256::ZERO, + balance: U256::ZERO, + nonce: 0, + code_hash: B256::ZERO, + slots: vec![ + (requested, U256::from(10)), + (requested, U256::from(11)), + (unexpected, U256::from(12)), + ], + }), + )] + })); + let required = StorageAccessList { + accounts: [target].into_iter().collect(), + slots: [(target, requested)].into_iter().collect(), + ..Default::default() + }; + + let report = cache.hydrate_read_set(&required); + + assert!(!report.is_complete()); + assert!(report.failures.iter().any(|failure| matches!( + failure, + ReadSetHydrationFailure::StorageSlotDuplicate { address, slot } + if *address == target && *slot == requested + ))); + assert!(report.failures.iter().any(|failure| matches!( + failure, + ReadSetHydrationFailure::StorageSlotUnexpected { address, slot } + if *address == target && *slot == unexpected + ))); + assert_eq!(cache.cached_storage_value(target, requested), None); + assert!(report.missing_after.slots.contains(&(target, requested))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn policy_skips_calls_only_when_remote_discovery_is_not_selected() { + let target = Address::repeat_byte(0x48); + for config in [ + ReadSetWarmupConfig { + strategy: ReadSetWarmupStrategy::LocalOnly, + ..Default::default() + }, + ReadSetWarmupConfig { + strategy: ReadSetWarmupStrategy::Auto, + ..Default::default() + }, + ] { + let mut cache = cache_without_fetchers().await; + let report = cache + .prewarm_read_sets( + ReadSetWarmupBatch { + known_slots: Vec::new(), + calls: vec![ReadSetWarmupCall { + tx: TransactionRequest::default().to(target), + expected_slots: Some(1), + restrict_to: None, + }], + }, + config, + ) + .expect("policy did not select remote discovery"); + + assert!(!report.used_access_lists); + assert_eq!(report.skipped_calls, 1); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn access_list_discovery_preserves_per_call_failures() { + let mut cache = cache().await; + cache.set_access_list_fetcher(Arc::new(|_requests, _block| { + vec![Err(evm_fork_cache::AccessListError::query( + "eth_createAccessList", + "provider rejected the call", + ))] + })); + + let report = cache + .prewarm_read_sets( + ReadSetWarmupBatch { + known_slots: Vec::new(), + calls: vec![ReadSetWarmupCall { + tx: TransactionRequest::default().to(Address::repeat_byte(0x49)), + expected_slots: None, + restrict_to: None, + }], + }, + ReadSetWarmupConfig { + strategy: ReadSetWarmupStrategy::AccessList, + ..Default::default() + }, + ) + .expect("the batch contract was valid"); + + assert!(report.used_access_lists); + assert_eq!(report.access_list_successes, 0); + assert_eq!(report.access_list_failures.len(), 1); + assert_eq!(report.access_list_failures[0].0, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn exact_hydration_preserves_typed_partial_failure_causes() { + let mut cache = cache().await; + let omitted = Address::repeat_byte(0x4a); + let provider_failed = Address::repeat_byte(0x4b); + let runtime_missing = Address::repeat_byte(0x4c); + let slot_missing = Address::repeat_byte(0x4d); + let slot = U256::from(9); + let deployed_hash = B256::repeat_byte(0x4e); + cache.set_account_proof_fetcher(Arc::new(move |_requests, _block| { + vec![ + ( + provider_failed, + Err(StorageFetchError::custom("archive unavailable")), + ), + ( + runtime_missing, + Ok(AccountProof { + storage_hash: B256::ZERO, + balance: U256::ZERO, + nonce: 1, + code_hash: deployed_hash, + slots: Vec::new(), + }), + ), + ( + slot_missing, + Ok(AccountProof { + storage_hash: B256::ZERO, + balance: U256::ZERO, + nonce: 0, + code_hash: B256::ZERO, + slots: Vec::new(), + }), + ), + ] + })); + let required = StorageAccessList { + accounts: [omitted, provider_failed, runtime_missing, slot_missing] + .into_iter() + .collect(), + slots: [(slot_missing, slot)].into_iter().collect(), + ..Default::default() + }; + + let report = cache.hydrate_read_set(&required); + + assert!(report.failures.iter().any(|failure| matches!( + failure, + ReadSetHydrationFailure::ProofResultMissing { address } if *address == omitted + ))); + assert!(report.failures.iter().any(|failure| matches!( + failure, + ReadSetHydrationFailure::ProofFetch { address, source } + if *address == provider_failed && source.to_string().contains("archive unavailable") + ))); + assert!(report.failures.iter().any(|failure| matches!( + failure, + ReadSetHydrationFailure::RuntimeCodeUnavailable { address, code_hash } + if *address == runtime_missing && *code_hash == deployed_hash + ))); + assert!(report.failures.iter().any(|failure| matches!( + failure, + ReadSetHydrationFailure::StorageSlotMissing { address, slot: missing } + if *address == slot_missing && *missing == slot + ))); + assert!(!report.is_complete()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn block_hash_dependencies_are_validated_from_canonical_cache_residency() { + let mut cache = cache().await; + let resident_number = 100_u64; + let missing_number = 101_u64; + cache + .db_mut() + .cache + .block_hashes + .insert(U256::from(resident_number), B256::repeat_byte(0x4f)); + + let resident = cache.hydrate_read_set(&StorageAccessList { + block_numbers: [resident_number].into_iter().collect(), + ..Default::default() + }); + let missing = cache.hydrate_read_set(&StorageAccessList { + block_numbers: [missing_number].into_iter().collect(), + ..Default::default() + }); + + assert!(resident.is_complete(), "{resident:?}"); + assert!(missing.failures.is_empty()); + assert_eq!( + missing.missing_after.block_numbers, + [missing_number].into_iter().collect() + ); + assert!(!missing.is_complete()); +} + #[tokio::test(flavor = "multi_thread")] async fn cache_owned_warmup_discovers_filters_and_loads_slots() { let mut cache = cache().await; @@ -37,20 +463,22 @@ async fn cache_owned_warmup_discovers_filters_and_loads_slots() { vec![(target, slot, Ok(U256::from(99)))] })); - let report = cache.prewarm_read_sets( - ReadSetWarmupBatch { - known_slots: Vec::new(), - calls: vec![ReadSetWarmupCall { - tx: TransactionRequest::default().to(target), - expected_slots: Some(32), - restrict_to: Some(vec![target]), - }], - }, - ReadSetWarmupConfig { - strategy: ReadSetWarmupStrategy::AccessList, - ..Default::default() - }, - ); + let report = cache + .prewarm_read_sets( + ReadSetWarmupBatch { + known_slots: Vec::new(), + calls: vec![ReadSetWarmupCall { + tx: TransactionRequest::default().to(target), + expected_slots: Some(32), + restrict_to: Some(vec![target]), + }], + }, + ReadSetWarmupConfig { + strategy: ReadSetWarmupStrategy::AccessList, + ..Default::default() + }, + ) + .expect("access-list warmup"); assert!(report.used_access_lists); assert_eq!(report.access_list_successes, 1); From e51aaf2e17275c73af0809073c5bcba8ddf23805 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 4 Aug 2026 23:25:46 +0100 Subject: [PATCH 4/8] Finalize alpha.2 package guidance --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 15 +++++++++++++- Cargo.toml | 12 ++++++++--- RELEASING.md | 6 ++++-- SECURITY.md | 2 +- tests/public_release_surface.rs | 35 +++++++++++++++++++++++++++++++++ 6 files changed, 64 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c89e3e..e31221c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: env: CARGO_TERM_COLOR: always - TRANSPORT_REF: 5d012b8b848cd061c9aa909a17597cd4c303f4b7 + TRANSPORT_REF: b84a24823286313cf6ab19e2da4b38724e925dba permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index c4e4d47..1eeefef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,20 @@ surface freezes at 1.0. ## [Unreleased] -## [0.4.0-alpha.2] - 2026-07-29 +## [0.4.0-alpha.2] - 2026-08-04 + +### Migration checklist + +- Replace `FlashblockRef::block_hash` reads with + `FlashblockRef::content_hash` when identifying, comparing, or deduplicating + one exact cumulative speculative view. The content hash is scoped to the + provider generation and must never be treated as a canonical block hash. + Use the new optional `FlashblockRef::partial_block_hash` only when the + provider's non-placeholder partial hash is needed, and keep it speculative. +- `ChainStatus::Preconfirmed::flashblock` is now an `Arc` so all + logs from one cumulative preview share the same identity cheaply. Wrap + constructed values with `Arc::new`, borrow through the `Arc` for reads, and + use `Arc::clone` when retaining the reference. ### Added diff --git a/Cargo.toml b/Cargo.toml index 55d39ff..4e126a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,15 @@ documentation = "https://docs.rs/evm-fork-cache" # Keep the published crate lean: development specs and release plans (per-phase # build orders with internal line references) are planning artifacts, and the CI -# workflow has no consumer value. The consumer-facing ROADMAP.md, KNOWN_ISSUES.md, -# INTERNALS.md, and benchmark notes under docs/ are still shipped. -exclude = ["docs/*-spec.md", "docs/*-plan.md", ".github/"] +# workflow and source-tree release audit have no consumer value. The +# consumer-facing ROADMAP.md, KNOWN_ISSUES.md, INTERNALS.md, and benchmark notes +# under docs/ are still shipped. +exclude = [ + "docs/*-spec.md", + "docs/*-plan.md", + ".github/", + "tests/public_release_surface.rs", +] # Build the docs.rs page with every feature enabled so the full surface — the # reactive runtime, the default WebSocket subscriber, the opt-in polling diff --git a/RELEASING.md b/RELEASING.md index 9885c69..56c45e5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -51,8 +51,10 @@ substitute. Inspect `cargo package --list --locked` and confirm that secrets, local databases, planning/spec documents, and build output are excluded while consumer documentation, tests, examples, and benchmarks needed to understand the public -surface are present. Run authenticated examples or probes only before this -clean-tree preflight, never as part of packaging. +surface are present. The source-only `tests/public_release_surface.rs` audit must +remain excluded because it reads CI and archival planning files that are +intentionally absent from the consumer package. Run authenticated examples or +probes only before this clean-tree preflight, never as part of packaging. Before publishing a durable subscriber extension, exercise a real multi-block checkpoint restart through diff --git a/SECURITY.md b/SECURITY.md index 79b1ac6..fa1229e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -108,6 +108,6 @@ the new upstream ref and full commit before changing the pin. Sibling development dependencies are also immutable in CI. The alpha.2 cache workflow checks out `alloy-transport-balancer` at exact commit -`5d012b8b848cd061c9aa909a17597cd4c303f4b7`, matching the first candidate in +`b84a24823286313cf6ab19e2da4b38724e925dba`, matching the first candidate in the documented publish order. Changing that revision requires rerunning the cache's complete locked release matrix. diff --git a/tests/public_release_surface.rs b/tests/public_release_surface.rs index b74b032..9965131 100644 --- a/tests/public_release_surface.rs +++ b/tests/public_release_surface.rs @@ -20,6 +20,41 @@ fn manifest_no_longer_defines_protocols_feature_or_protocol_benchmarks() { } } +#[test] +fn published_package_excludes_source_tree_release_audits() { + let manifest = read("Cargo.toml"); + + assert!( + manifest.contains("tests/public_release_surface.rs"), + "the source-tree release audit depends on files intentionally omitted from the published crate" + ); +} + +#[test] +fn alpha2_changelog_explains_flashblock_identity_migration() { + let changelog = read("CHANGELOG.md"); + let alpha2 = changelog + .split("## [0.4.0-alpha.2]") + .nth(1) + .expect("alpha.2 changelog section") + .split("## [0.4.0-alpha.1]") + .next() + .expect("alpha.2 changelog boundary"); + + for required in [ + "### Migration checklist", + "`FlashblockRef::block_hash`", + "`FlashblockRef::content_hash`", + "`FlashblockRef::partial_block_hash`", + "`Arc`", + ] { + assert!( + alpha2.contains(required), + "alpha.2 migration guidance should mention {required}" + ); + } +} + #[test] fn protocol_modules_are_not_part_of_the_core_crate_surface() { for path in [ From 2be88d15fa15b5c44188b318463aa4705bb75aef Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Wed, 5 Aug 2026 00:34:31 +0100 Subject: [PATCH 5/8] Set alpha.2 release date and transport pin --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 +- SECURITY.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e31221c..08ba8a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: env: CARGO_TERM_COLOR: always - TRANSPORT_REF: b84a24823286313cf6ab19e2da4b38724e925dba + TRANSPORT_REF: 7868bea593dec5748ad7475d1909fc3a2de0d4ad permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eeefef..aa32a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ surface freezes at 1.0. ## [Unreleased] -## [0.4.0-alpha.2] - 2026-08-04 +## [0.4.0-alpha.2] - 2026-08-05 ### Migration checklist diff --git a/SECURITY.md b/SECURITY.md index fa1229e..259a3e2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -108,6 +108,6 @@ the new upstream ref and full commit before changing the pin. Sibling development dependencies are also immutable in CI. The alpha.2 cache workflow checks out `alloy-transport-balancer` at exact commit -`b84a24823286313cf6ab19e2da4b38724e925dba`, matching the first candidate in +`7868bea593dec5748ad7475d1909fc3a2de0d4ad`, matching the first candidate in the documented publish order. Changing that revision requires rerunning the cache's complete locked release matrix. From f534cfcf513c07cc64d3d04a4d65b5e92230154f Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Fri, 7 Aug 2026 23:42:10 +0100 Subject: [PATCH 6/8] Release evm-fork-cache 0.4.0-alpha.3 --- .github/workflows/ci.yml | 8 +- CHANGELOG.md | 66 +- Cargo.lock | 5 +- Cargo.toml | 24 +- README.md | 126 +- RELEASING.md | 36 +- benches/raw_json_flashblocks.rs | 294 ++++ docs/KNOWN_ISSUES.md | 36 +- docs/ROADMAP.md | 13 +- docs/raw-json-flashblocks-acceptance.md | 114 ++ ..._json_flashblocks_subscriber_acceptance.rs | 477 ++++++ scripts/check-security-exceptions.sh | 2 +- src/reactive/mod.rs | 1386 ++++++++++++++++- src/reactive/raw_json_flashblocks.rs | 837 ++++++++++ tests/public_release_surface.rs | 47 + tests/raw_json_flashblocks.rs | 1281 +++++++++++++++ tests/raw_json_flashblocks_runtime.rs | 482 ++++++ tests/reactive_flashblocks.rs | 19 +- 18 files changed, 5189 insertions(+), 64 deletions(-) create mode 100644 benches/raw_json_flashblocks.rs create mode 100644 docs/raw-json-flashblocks-acceptance.md create mode 100644 examples/raw_json_flashblocks_subscriber_acceptance.rs create mode 100644 src/reactive/raw_json_flashblocks.rs create mode 100644 tests/raw_json_flashblocks.rs create mode 100644 tests/raw_json_flashblocks_runtime.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08ba8a3..b595451 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,8 +77,12 @@ jobs: cargo check --locked --no-default-features --features reactive cargo check --locked --no-default-features --features reactive-polling cargo check --locked --no-default-features --features reactive-ws + cargo check --locked --no-default-features --features raw-flashblocks-json cargo clippy --locked --all-targets --no-default-features --features reactive-polling --no-deps -- -D warnings cargo test --locked --no-default-features --features reactive-polling + cargo clippy --locked --lib --test raw_json_flashblocks --no-default-features --features raw-flashblocks-json --no-deps -- -D warnings + cargo test --locked --no-default-features --features raw-flashblocks-json --test raw_json_flashblocks + cargo test --locked --no-default-features --features raw-flashblocks-json,reactive-polling --test raw_json_flashblocks_runtime - name: Benchmarks compile run: cargo bench --locked --no-run --all-features @@ -127,4 +131,6 @@ jobs: # Scoped to --lib so the dev-only example/bench toolchain requirements # (e.g. criterion) do not constrain the consumer-facing MSRV. - name: Check library builds on MSRV - run: cargo check --lib --locked + run: | + cargo check --lib --locked + cargo check --lib --locked --no-default-features --features raw-flashblocks-json diff --git a/CHANGELOG.md b/CHANGELOG.md index aa32a7b..22d4849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,69 @@ surface freezes at 1.0. ## [Unreleased] +### Changed + +- Canonical-head certification requests used by Flashblocks generations now + fail closed after `SubscriberConfig::canonical_head_request_timeout` (three + seconds by default). A silently wedged request can therefore surface through + subscriber-driver failure and provider rotation instead of leaving canonical + progress indefinitely stalled. + +## [0.4.0-alpha.3] - 2026-08-07 + +### Added + +- Added the default-off `raw-flashblocks-json` feature with a chain-neutral, + transport-free adapter for receipt-enriched indexed JSON Flashblocks. It + validates payload sequencing, exact receipt/transaction membership, bounded + resource use, cumulative identity, and structured log provenance before + emitting the existing standardized preconfirmation types. +- Added the construction-only, fallible + `AlloySubscriber::configure_external_flashblock_updates` API and synchronous + `ingest_flashblock_update` so application-managed sources can enter the same + speculative overlay, invalidation, and canonical reconciliation path without + adding provider requests. +- Added a single-open, bounded update channel so an application can retain a + cloneable sender after moving `AlloySubscriber` into a downstream runtime + owner. Every send now returns the subscriber's validation verdict rather than + queue admission alone; non-blocking sends return an awaitable acknowledgement. + Channel closure revokes the active preview; preferred mode retains canonical + delivery, while required mode fails closed. +- Added an opt-in, caller-owned WebSocket acceptance example that forwards the + supported raw profile through `AlloySubscriber` and measures speculative to + canonical swap-log reconciliation without HTTP RPC or source retries. + +### Changed + +- Selecting an externally managed standardized source suppresses the built-in + chain-specific Flashblocks source while retaining ordinary canonical pubsub + logs and block headers. Socket control, timeout, retry, backoff, rate limiting, + and provider rotation remain application responsibilities. +- External snapshots now reject an unexpected endpoint, invalid content + commitment, duplicate JSON receipt-map key, duplicate transaction or log + identity, or log/block membership mismatch, while stale generations are + ignored and cannot revoke newer state. + Subscriber-level validation now independently enforces an index-zero start, + exact index progression, exact same-index duplicates, stable base identity, + cumulative transaction prefixes, and delta logs belonging only to appended + transactions. A missing initial index, gap, conflicting duplicate, or caller + reset emits an explicit generation-scoped invalidation. +- Recoverable local-capacity rejection no longer quarantines a provider + generation. Queued rejection is reported to the application so it can revoke + and reconnect the source while canonical delivery stays active. +- Complete interest-owner replacement now preserves an invalidation when it + retires an active or queued speculative preview, preventing stale overlay + state from surviving a topology reset. +- `ReactiveRuntime` now admits pre-confirmed state only when its pending block + is the exact numbered child of the adopted canonical coverage hash. Missing + baselines, missing or wrong parents, and stale replays after canonical + advancement fail closed and revoke any active overlay. +- Added a bounded near-limit conversion benchmark. The crate's 16 MiB default + is a defensive compatibility ceiling, not a recommended application latency + budget; consumers should qualify their exact source and configure materially + smaller byte, index, transaction, and log limits where its observed profile + permits. + ## [0.4.0-alpha.2] - 2026-08-05 ### Migration checklist @@ -1139,7 +1202,8 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). - `EvmCache` requires a multi-thread tokio runtime for any RPC-touching path. - See [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md) for current limitations. -[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.2...HEAD +[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.3...HEAD +[0.4.0-alpha.3]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.2...v0.4.0-alpha.3 [0.4.0-alpha.2]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.1...v0.4.0-alpha.2 [0.4.0-alpha.1]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.3.0...v0.4.0-alpha.1 [0.3.0]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.2.1...v0.3.0 diff --git a/Cargo.lock b/Cargo.lock index 0b0da62..eaf5072 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2061,11 +2061,12 @@ dependencies = [ [[package]] name = "evm-fork-cache" -version = "0.4.0-alpha.2" +version = "0.4.0-alpha.3" dependencies = [ "alloy-consensus", "alloy-contract", "alloy-eips", + "alloy-json-rpc", "alloy-network", "alloy-node-bindings", "alloy-primitives", @@ -2090,6 +2091,8 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "tokio-tungstenite", + "tower", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index 4e126a6..3d941dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "evm-fork-cache" -version = "0.4.0-alpha.2" +version = "0.4.0-alpha.3" edition = "2024" rust-version = "1.90" license = "MIT OR Apache-2.0" @@ -25,9 +25,9 @@ exclude = [ # Build the docs.rs page with every feature enabled so the full surface — the # reactive runtime, the default WebSocket subscriber, the opt-in polling -# transport, and cold-start — is all documented (docs.rs builds with default -# features otherwise). `--cfg docsrs` is the conventional hook for any -# feature-gated doc annotations. +# transport, the transport-free raw JSON Flashblocks adapter, and cold-start — +# is all documented (docs.rs builds with default features otherwise). `--cfg +# docsrs` is the conventional hook for any feature-gated doc annotations. [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] @@ -41,6 +41,10 @@ default = ["reactive", "reactive-ws"] reactive = [] reactive-ws = ["reactive", "alloy-provider/ws", "dep:rustls", "rustls/ring"] reactive-polling = ["reactive"] +# Receipt-enriched indexed JSON conversion plus a bounded in-process handoff. +# The application owns the source socket and its complete lifecycle; this +# feature adds no network transport. +raw-flashblocks-json = ["reactive", "tokio/sync"] [dependencies] alloy-consensus = ">=1.1.2, <1.7" @@ -74,6 +78,7 @@ tracing = "0.1.41" [dev-dependencies] anyhow = "1.0.98" +alloy-json-rpc = ">=1.0.38, <1.7" alloy-node-bindings = ">=1.1.2, <1.7" alloy-rpc-client = { version = ">=1.0.38, <1.7", features = ["reqwest"] } alloy-transport = ">=1.0.38, <1.7" @@ -88,6 +93,8 @@ reqwest = { version = "0.12", default-features = false, features = ["gzip"] } # `macros` powers `#[tokio::main]`/`#[tokio::test]` in the examples and tests. # `time` lets live RPC examples bound their subscription windows. tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "time"] } +tokio-tungstenite = { version = "=0.26.2", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } +tower = "0.5" [[bench]] name = "revert_decoding" @@ -117,6 +124,11 @@ harness = false name = "state_update" harness = false +[[bench]] +name = "raw_json_flashblocks" +harness = false +required-features = ["raw-flashblocks-json"] + [[bench]] name = "event_pipeline" harness = false @@ -146,6 +158,10 @@ required-features = ["reactive"] name = "cold_start" required-features = ["reactive"] +[[example]] +name = "raw_json_flashblocks_subscriber_acceptance" +required-features = ["raw-flashblocks-json", "reactive-ws"] + # RPC-gated real-contract benchmarks. Skipped (not failed) when RPC_URL is unset, # so `cargo bench` stays offline by default. [[bench]] diff --git a/README.md b/README.md index 4529115..7e2d721 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,10 @@ The reactive subscriber contract became asynchronous and explicitly durable in - Attach a `ProviderRef` to Flashblocks-enabled `AlloySubscriber` sessions. The endpoint ID is propagated into every preconfirmed record so pending reads can remain pinned to the announcing provider and later canonical reads can prefer it. +- Enable `raw-flashblocks-json` only when an application receives the supported + receipt-enriched indexed JSON profile on a separate source socket. The crate + converts application-data frames but never opens, reconnects, or rate-limits + that socket. ## What it provides today @@ -315,7 +319,7 @@ The reactive subscriber contract became asynchronous and explicitly durable in crates implement the versioned remote service client and a durable HyperSync source without coupling provider-native types into this core crate. -### Flashblocks on Base and OP +### Flashblocks delivery profiles Flashblocks are an opt-in subscriber mode layered onto the same handler and runtime path as canonical events: @@ -353,6 +357,9 @@ let subscriber = AlloySubscriber::new(provider, SubscriberMode::PubSub, config) complete speculative generation before reconnect I/O. - On Base Flashblocks endpoints, canonical progress is certified at `canonical_head_poll_interval` through `eth_getBlockByNumber("latest")`. + Each certification is bounded by `canonical_head_request_timeout` (three + seconds by default); expiry fails the subscriber generation closed so its + owner can rotate the pinned provider. The provider's `newHeads` feed is not trusted because Flashblocks-aware endpoints may expose partial/preconfirmed progress through it. - **OP** (`10`, `11155420`) uses one generation-pinned sampler for the standard @@ -367,13 +374,17 @@ let subscriber = AlloySubscriber::new(provider, SubscriberMode::PubSub, config) for canonical streams while routing OP pending reads through the matching provider's request/response endpoint. -Both adapters emit `ChainStatus::Preconfirmed`, `InputSource::Flashblocks`, and +The built-in adapters emit `ChainStatus::Preconfirmed`, `InputSource::Flashblocks`, and `DeliveryScope::Preconfirmed`. `ReactiveRuntime` applies each cumulative Flashblock to a disposable overlay: a newer payload/provider generation replaces the previous preview, canonical input restores the saved canonical state before commit, and `discard_preconfirmation` restores it explicitly. The cache pins preconfirmed reads to `pending` and installs the preview's complete available -EVM block environment. Preconfirmed resyncs also use the `pending` block tag. +EVM block environment. Admission requires an adopted canonical coverage head: +the preview number must be exactly `canonical + 1` and its parent hash must equal +the canonical coverage hash. A missing baseline, wrong or missing parent, or +stale replay after canonical advancement revokes the active overlay and fails +closed. Preconfirmed resyncs also use the `pending` block tag. The overlay never advances canonical coverage, finality, health, rollback journals, or durable checkpoints; the checkpointed engine rejects speculative batches rather than persisting them. @@ -389,6 +400,82 @@ qualification still requires a live acceptance window that observes advancing Flashblocks and a correlated active-pool pending log; acknowledgement or a successful probe alone is not liveness. +#### Receipt-enriched raw JSON adapter + +The default-off `raw-flashblocks-json` feature adds a chain-neutral converter +for one explicit wire profile: `payload_id`, a monotonically increasing `index`, +an index-zero `base` (or `static`) header, transaction deltas in +`diff.transactions`, and an exact receipt map in `metadata.receipts`. It does +not accept JSON-RPC subscription envelopes, receipt-less previews, or binary +SSZ frames. Compatibility is determined by this schema, not by a chain allowlist +or provider name. + +The adapter performs no network I/O and adds no WebSocket dependency. The +application owns authentication, control frames, bounded channel capacity, +inactivity detection, retry, backoff, and provider rotation. It should pass +only complete application-data frames to the adapter: + +```rust,ignore +use evm_fork_cache::reactive::{ + AlloySubscriber, PreconfirmationMode, ProviderRef, RawJsonFlashblocksAdapter, + SubscriberConfig, SubscriberMode, +}; + +let source = ProviderRef::new("supplemental-flashblocks", generation); +let mut adapter = RawJsonFlashblocksAdapter::new(source.clone()); +let mut subscriber = AlloySubscriber::new(canonical_provider, SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }); +subscriber.configure_external_flashblock_updates(source)?; +let updates = subscriber.open_external_flashblock_update_channel(1_024)?; + +// `subscriber` may now move into another runtime owner. The source task keeps +// the bounded sender and remains responsible for socket lifecycle policy. + +match adapter.ingest_json(application_frame) { + Ok(Some(update)) => updates.send(update).await?, + Ok(None) => {} // identical duplicate or remainder of an invalid generation + Err(error) => { + // Treat an untrusted application-data error as a source-generation + // failure. Forward reset()'s invalidation before reconnecting outside + // the crate with a fresh ProviderRef generation. + let next_source = ProviderRef::new( + adapter.provider().endpoint.clone(), + adapter.provider().generation.saturating_add(1), + ); + if let Some(invalidation) = adapter.reset(next_source)? { + updates.send(invalidation).await?; + } + } +} +``` + +Selecting external updates suppresses only the built-in native/pending +Flashblocks source. Ordinary canonical log and block-header subscriptions stay +active, so speculative delivery remains additive and canonical reconciliation +is unchanged. `ingest_flashblock_update` is synchronous, validates source +generation, exact indexed sequencing, stable base identity, cumulative +transaction prefixes, delta-log membership, log identity, and the content +commitment, and performs no provider request. Stale snapshots and invalidations +cannot revoke a newer generation. The optional bounded channel is an in-process +ownership seam, not a transport or retry loop. `send(...).await` completes only +after subscriber validation; `try_send` returns an acknowledgement receipt whose +`wait` method reports that later verdict. `Rejected` requires the application to +revoke and reconnect the source generation. Recoverable local-capacity rejection +does not permanently quarantine the endpoint. Channel closure revokes the active +preview, keeps canonical delivery alive in preferred mode, and fails required +mode closed. Call `RawJsonFlashblocksAdapter::reset` and forward its returned +invalidation whenever the source disconnects, is replaced, or returns an +application-data or subscriber-admission error that cannot be proven irrelevant. + +For an externally managed source, +`establish_flashblocks_preflight(expected_chain_id)` verifies the canonical +subscriber's chain and stream topology but deliberately performs zero +Flashblocks request/response calls. Notification liveness, schema compatibility, +and active-interest coverage remain application acceptance checks. + ### Execution read-set warming `StorageAccessList` covers accounts, runtime-code identities, storage slots, and @@ -678,18 +765,33 @@ endpoint (they print instructions and exit if it is unset): | `bulk_storage_bench` | Advanced | Benchmark bulk `eth_call` storage extraction vs point reads: scaling, multicall dispatch, a full Uniswap V3 tick-range load, gzip, verified code-seed cold starts, and the provider's chunk ceiling. | | `fork_override_balance` | Intermediate | Discover a real token's balance slot and override it. | | `reactive_alloy_amm_live_probe` | Advanced | Subscribe to live mainnet AMM logs through the WebSocket-backed `AlloySubscriber`. | +| `raw_json_flashblocks_subscriber_acceptance` | Advanced | Feed a caller-owned receipt-enriched raw WebSocket through the standardized subscriber path and correlate swap logs with an independent canonical WebSocket. Requires the default-off raw adapter feature and performs no HTTP RPC. | ```sh cargo run --example revert_decoding RPC_URL=https://eth.llamarpc.com cargo run --example fork_token_balance WS_RPC_URL=wss://example-mainnet-endpoint cargo run --example reactive_alloy_amm_live_probe +RAW_FLASHBLOCKS_WS_URL=wss://raw-endpoint.example \ +CANONICAL_WS_URL=wss://canonical-endpoint.example \ +cargo run --release --features raw-flashblocks-json,reactive-ws \ + --example raw_json_flashblocks_subscriber_acceptance ``` +The point-in-time raw/canonical acceptance results and their exact safety +boundary are recorded in +[`docs/raw-json-flashblocks-acceptance.md`](docs/raw-json-flashblocks-acceptance.md). +They qualify the supported wire profile observed in that run, not every raw +Flashblocks provider or future schema revision. + ## Feature Flags Default features enable the reactive runtime and WebSocket/pubsub subscriber support (`reactive`, `reactive-ws`). The HTTP polling subscriber is opt-in: consumers that disable defaults can enable `reactive,reactive-polling`. +The receipt-enriched raw JSON adapter is separately opt-in through +`raw-flashblocks-json`. Its networked acceptance example owns one passive raw +socket solely to demonstrate the consumer boundary; the library dependency +surface still contains no raw-socket transport or reconnect policy. ## Foundry artifact etching @@ -925,6 +1027,24 @@ provider) so they are reproducible: | `create3` | CREATE3 address derivation. | | `mapping_probe` | **Trace-based slot discovery.** `discover_erc20_balance_slot` across Solidity/Vyper/Solady (near-identical — the sim dominates, layout detection is a few hash checks); end-to-end balance forging **cold vs. descriptor-cached**; overlay `mock_balance`; and typed `call_sol` vs. `call_raw` + manual decode (within noise). | | `reactive_routing` | Indexed log hit/miss routing versus compatibility scans, plus fallback/distinct/shared-key handler churn at 16–4,096 handlers. | +| `raw_json_flashblocks` | Default-off receipt-enriched JSON conversion at one- and two-index fixtures, a 250-transaction/500-log payload, a bounded stress frame close to the 16 MiB compatibility ceiling, and standardized-update queue admission. No socket or provider I/O. | + +An Apple M1 Pro `arm64` release run on 2026-08-06 measured Criterion point +estimates of 4.814 µs for index-zero conversion, 3.440 µs for a following +delta, 459.46 µs (217.36 MiB/s) for the 250-transaction/500-log payload, and +9.496 µs for batched non-blocking queue admission. Subscriber validation and +its acknowledgement are excluded from that microbenchmark. These are offline +CPU regression baselines, not notification-lead or end-to-end provider latency. +The much larger stress case exists to make the worst permitted parsing budget +visible; the 16 MiB library default is not a recommended production setting. +Applications should record source frame/count distributions, add explicit +headroom, and set the four `RawJsonFlashblocksLimits` bounds accordingly. On the +same Apple M1 Pro in a 2026-08-07 release run, a 15,521,468-byte frame with +17,000 transactions and 34,000 logs measured 38.861 ms (38.356–39.504 ms 95% +confidence interval) and 380.90 MiB/s across 20 flat Criterion samples. A +4,108,968-byte application-limit frame with 4,500 transactions and 9,000 logs +measured 9.642 ms (9.291–10.209 ms) and 406.40 MiB/s; two of its 20 samples were +high severe outliers. ```sh cargo bench # all offline benches diff --git a/RELEASING.md b/RELEASING.md index 56c45e5..dd7ff1e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,10 +1,11 @@ # Releasing -`evm-fork-cache` 0.4.0-alpha.2 is the second prerelease in the Flashblocks -compatibility set. Publish `alloy-transport-balancer 0.3.0-alpha.2` first, then -publish this crate before any extension crate that declares -`evm-fork-cache = "0.4.0-alpha.2"`, including `evm-amm-state 0.3.0-alpha.2` and -the remote/Hybrid subscriber packages. +`evm-fork-cache` 0.4.0-alpha.3 adds the default-off raw JSON Flashblocks +normalization layer to the existing Flashblocks compatibility set. Publish +`alloy-transport-balancer 0.3.0-alpha.2` first, then publish this crate before +any extension crate that declares `evm-fork-cache = "0.4.0-alpha.3"`, +including `evm-amm-state 0.3.0-alpha.4` and the remote/Hybrid subscriber +packages. No release step is automatic: use clean, reviewed commits and never publish from a credential-bearing working tree. @@ -21,10 +22,17 @@ cargo check --locked --no-default-features cargo check --locked --no-default-features --features reactive cargo check --locked --no-default-features --features reactive-polling cargo check --locked --no-default-features --features reactive-ws +cargo check --locked --no-default-features --features raw-flashblocks-json cargo clippy --locked --all-targets --no-default-features --features reactive-polling --no-deps -- -D warnings cargo test --locked --no-default-features --features reactive-polling +cargo clippy --locked --lib --test raw_json_flashblocks --no-default-features --features raw-flashblocks-json --no-deps -- -D warnings +cargo test --locked --no-default-features --features raw-flashblocks-json --test raw_json_flashblocks +cargo test --locked --no-default-features --features raw-flashblocks-json,reactive-polling --test raw_json_flashblocks_runtime +cargo clippy --locked --example raw_json_flashblocks_subscriber_acceptance --features raw-flashblocks-json,reactive-ws --no-deps -- -D warnings cargo +1.90.0 check --locked --lib cargo bench --no-run --all-features --locked +cargo bench --locked --bench raw_json_flashblocks --no-default-features --features raw-flashblocks-json -- raw_json_flashblocks_application_limit +cargo bench --locked --bench raw_json_flashblocks --no-default-features --features raw-flashblocks-json -- raw_json_flashblocks_near_limit bash scripts/check-authoring-hygiene.sh bash scripts/check-security-exceptions.sh cargo audit --ignore RUSTSEC-2025-0055 @@ -55,6 +63,18 @@ surface are present. The source-only `tests/public_release_surface.rs` audit mus remain excluded because it reads CI and archival planning files that are intentionally absent from the consumer package. Run authenticated examples or probes only before this clean-tree preflight, never as part of packaging. +For a raw-profile release candidate, run +`raw_json_flashblocks_subscriber_acceptance` against an independent canonical +WebSocket for 100 matched swaps or five minutes. Record the exact provider, +window, pairing ratio, raw-first latency distribution, duplicate counts, and +canonical-head continuity. The probe must remain opt-in and is not a publishing +side effect. + +Record the near-limit frame size, Criterion latency interval, and throughput in +`docs/raw-json-flashblocks-acceptance.md`. Treat the 16 MiB library default as a +defensive compatibility ceiling, not an application recommendation. Verify each +production consumer checks in explicit, source-qualified frame, index, +transaction, and log limits before promotion. Before publishing a durable subscriber extension, exercise a real multi-block checkpoint restart through @@ -67,11 +87,11 @@ the core's retained canonical history exactly. ```bash cargo publish --locked -git tag -s v0.4.0-alpha.2 -m "Release evm-fork-cache v0.4.0-alpha.2" -git push origin v0.4.0-alpha.2 +git tag -s v0.4.0-alpha.3 -m "Release evm-fork-cache v0.4.0-alpha.3" +git push origin v0.4.0-alpha.3 ``` -Wait for 0.4.0-alpha.2 to appear in the crates.io index before removing sibling path +Wait for 0.4.0-alpha.3 to appear in the crates.io index before removing sibling path dependencies and verifying downstream extension packages. Publish only after explicit authorization; preparing or running this checklist is not permission to publish, tag, or push. diff --git a/benches/raw_json_flashblocks.rs b/benches/raw_json_flashblocks.rs new file mode 100644 index 0000000..d6c418e --- /dev/null +++ b/benches/raw_json_flashblocks.rs @@ -0,0 +1,294 @@ +use std::time::Duration; + +use alloy_network::Ethereum; +use alloy_primitives::{B256, Bytes, keccak256}; +use alloy_provider::ProviderBuilder; +use alloy_transport::mock::Asserter; +use criterion::{BatchSize, Criterion, SamplingMode, Throughput, criterion_group, criterion_main}; +use evm_fork_cache::reactive::{ + AlloySubscriber, PreconfirmationMode, ProviderRef, RawJsonFlashblocksAdapter, + RawJsonFlashblocksLimits, SubscriberConfig, SubscriberMode, +}; + +const TX_TWO: B256 = + alloy_primitives::b256!("f2ee15ea639b73fa3db9b34a245bdfa015c260c598b211bf05a1ecc4b3e3b4f2"); + +fn index_zero() -> &'static [u8] { + br#"{ + "payload_id":"0x1111111111111111", + "index":0, + "base":{ + "parent_hash":"0x6464646464646464646464646464646464646464646464646464646464646464", + "block_number":"0x65", + "timestamp":"0x6553f165", + "gas_limit":"0x1c9c380", + "base_fee_per_gas":"0x7" + }, + "diff":{ + "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "transactions":["0x01"] + }, + "metadata":{ + "block_number":101, + "receipts":{ + "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{ + "logs":[{ + "address":"0x4242424242424242424242424242424242424242", + "topics":["0x4343434343434343434343434343434343434343434343434343434343434343"], + "data":"0x0102" + }] + } + } + } + }"# +} + +fn index_one() -> Vec { + format!( + r#"{{ + "payload_id":"0x1111111111111111", + "index":1, + "diff":{{ + "state_root":"0xabababababababababababababababababababababababababababababababab", + "block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "transactions":["0x02"] + }}, + "metadata":{{ + "block_number":"0x65", + "receipts":{{ + "{TX_TWO:#x}":{{ + "logs":[{{ + "address":"0x4444444444444444444444444444444444444444", + "topics":[], + "data":"0x03" + }}] + }} + }} + }} + }}"#, + ) + .into_bytes() +} + +fn adapter() -> RawJsonFlashblocksAdapter { + RawJsonFlashblocksAdapter::new(ProviderRef::new("benchmark", 1)) +} + +fn scaled_frame(transaction_count: usize, logs_per_transaction: usize) -> Vec { + scaled_frame_with_log_data(transaction_count, logs_per_transaction, "0x01020304") +} + +fn scaled_frame_with_log_data( + transaction_count: usize, + logs_per_transaction: usize, + log_data: &str, +) -> Vec { + let mut transactions = Vec::with_capacity(transaction_count); + let mut receipts = serde_json::Map::with_capacity(transaction_count); + for transaction_index in 0..transaction_count { + let raw = format!("0x02{:016x}", transaction_index + 1); + let bytes = raw.parse::().expect("benchmark transaction bytes"); + let hash = keccak256(bytes); + transactions.push(serde_json::Value::String(raw)); + let logs = (0..logs_per_transaction) + .map(|log_index| { + serde_json::json!({ + "address": format!("0x{:040x}", transaction_index + 1), + "topics": [format!("0x{:064x}", log_index + 1)], + "data": log_data + }) + }) + .collect::>(); + receipts.insert(format!("{hash:#x}"), serde_json::json!({ "logs": logs })); + } + serde_json::to_vec(&serde_json::json!({ + "payload_id": "0x1111111111111111", + "index": 0, + "base": { + "parent_hash": "0x6464646464646464646464646464646464646464646464646464646464646464", + "block_number": "0x65", + "timestamp": "0x6553f165", + "gas_limit": "0x1c9c380", + "base_fee_per_gas": "0x7" + }, + "diff": { + "state_root": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "transactions": transactions + }, + "metadata": { + "block_number": 101, + "receipts": receipts + } + })) + .expect("serialize scaled benchmark frame") +} + +fn benchmark_raw_json_flashblocks(criterion: &mut Criterion) { + let mut group = criterion.benchmark_group("raw_json_flashblocks"); + group.bench_function("index_zero_decode_normalize", |bencher| { + bencher.iter_batched( + adapter, + |mut adapter| { + std::hint::black_box( + adapter + .ingest_json(std::hint::black_box(index_zero())) + .expect("benchmark fixture"), + ) + }, + BatchSize::SmallInput, + ); + }); + + let next = index_one(); + group.bench_function("next_delta_decode_normalize", |bencher| { + bencher.iter_batched( + || { + let mut adapter = adapter(); + adapter + .ingest_json(index_zero()) + .expect("benchmark base fixture"); + adapter + }, + |mut adapter| { + std::hint::black_box( + adapter + .ingest_json(std::hint::black_box(&next)) + .expect("benchmark delta fixture"), + ) + }, + BatchSize::SmallInput, + ); + }); + group.finish(); + + let scaled = scaled_frame(250, 2); + let mut scaled_group = criterion.benchmark_group("raw_json_flashblocks_scaled"); + scaled_group.throughput(Throughput::Bytes( + u64::try_from(scaled.len()).expect("benchmark frame length fits u64"), + )); + scaled_group.bench_function("250_transactions_500_logs", |bencher| { + bencher.iter_batched( + adapter, + |mut adapter| { + std::hint::black_box( + adapter + .ingest_json(std::hint::black_box(&scaled)) + .expect("scaled benchmark fixture"), + ) + }, + BatchSize::SmallInput, + ); + }); + scaled_group.finish(); + + let qualified_application_limit = 4 * 1024 * 1024; + let application_limit = + scaled_frame_with_log_data(4_500, 2, &format!("0x{}", "01".repeat(128))); + assert!(application_limit.len() <= qualified_application_limit); + assert!(application_limit.len() >= 3 * 1024 * 1024); + let mut application_group = criterion.benchmark_group("raw_json_flashblocks_application_limit"); + application_group.sample_size(20); + application_group.sampling_mode(SamplingMode::Flat); + application_group.warm_up_time(Duration::from_secs(2)); + application_group.measurement_time(Duration::from_secs(10)); + application_group.throughput(Throughput::Bytes( + u64::try_from(application_limit.len()).expect("benchmark frame length fits u64"), + )); + application_group.bench_function( + format!( + "4500_transactions_9000_logs_{}_bytes", + application_limit.len() + ), + |bencher| { + bencher.iter_batched( + adapter, + |mut adapter| { + std::hint::black_box( + adapter + .ingest_json(std::hint::black_box(&application_limit)) + .expect("application-limit benchmark fixture"), + ) + }, + BatchSize::LargeInput, + ); + }, + ); + application_group.finish(); + + // Exercise the parser close to its intentionally conservative library + // ceiling without making every consumer pay that memory/latency budget. + // Applications should qualify and configure smaller limits for their + // observed provider profile. + let near_limit = scaled_frame_with_log_data(17_000, 2, &format!("0x{}", "01".repeat(128))); + let default_frame_limit = RawJsonFlashblocksLimits::default().max_frame_bytes; + assert!(near_limit.len() <= default_frame_limit); + assert!(near_limit.len() >= 12 * 1024 * 1024); + let mut stress_group = criterion.benchmark_group("raw_json_flashblocks_near_limit"); + stress_group.sample_size(20); + stress_group.sampling_mode(SamplingMode::Flat); + stress_group.warm_up_time(Duration::from_secs(2)); + stress_group.measurement_time(Duration::from_secs(20)); + stress_group.throughput(Throughput::Bytes( + u64::try_from(near_limit.len()).expect("benchmark frame length fits u64"), + )); + stress_group.bench_function( + format!("17000_transactions_34000_logs_{}_bytes", near_limit.len()), + |bencher| { + bencher.iter_batched( + adapter, + |mut adapter| { + std::hint::black_box( + adapter + .ingest_json(std::hint::black_box(&near_limit)) + .expect("near-limit benchmark fixture"), + ) + }, + BatchSize::LargeInput, + ); + }, + ); + stress_group.finish(); + + let handoff_frame = index_zero(); + let mut handoff_group = criterion.benchmark_group("raw_json_flashblocks_handoff"); + handoff_group.bench_function("bounded_try_send", |bencher| { + bencher.iter_batched( + || { + let source = ProviderRef::new("benchmark", 1); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure benchmark external source"); + let sender = subscriber + .open_external_flashblock_update_channel(1) + .expect("benchmark update channel"); + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let update = adapter + .ingest_json(handoff_frame) + .expect("handoff benchmark fixture") + .expect("handoff benchmark snapshot"); + (sender, subscriber, update) + }, + |(sender, subscriber, update)| { + let acknowledgement = sender.try_send(update).expect("bounded handoff"); + drop(std::hint::black_box(acknowledgement)); + std::hint::black_box(subscriber); + }, + BatchSize::SmallInput, + ); + }); + handoff_group.finish(); +} + +criterion_group!(benches, benchmark_raw_json_flashblocks); +criterion_main!(benches); diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 47f51d8..d9be61f 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -306,12 +306,40 @@ surface was moved out of this crate. tests). Composing `AlloySubscriber` output into `ReactiveRuntime::ingest_batch` end-to-end is now covered offline in `tests/reactive_subscriber_ingest.rs` (a real subscriber batch, produced via the mockable `get_logs` backfill path, - drives a real runtime ingest and asserts the cache write). The remaining paths - without dedicated integration coverage are the `EventDecoderHandler` adapter + drives a real runtime ingest and asserts the cache write). Raw JSON preview, + invalidation, replacement, and canonical reconciliation are likewise covered + in `tests/raw_json_flashblocks_runtime.rs`. The paths without dedicated + integration coverage are the `EventDecoderHandler` adapter and custom pending-tx matcher/route-key routing. Block-header ingestion is covered in `tests/block_context.rs`, and decoded-report delivery is asserted in `tests/reactive_engine.rs`. The live WebSocket transport plumbing is - covered by reconnect/termination unit tests but not by a networked end-to-end - test. These are tracked follow-ups, not known defects. + covered by reconnect/termination unit tests. The opt-in + `raw_json_flashblocks_subscriber_acceptance` example covers one caller-owned + raw socket through standardized speculative/canonical subscriber delivery, + but it is intentionally not part of offline CI and does not validate every + provider wire profile. These are tracked follow-ups, not known defects. +- **The optional raw JSON Flashblocks adapter supports one exact wire profile, + not arbitrary raw feeds.** With `raw-flashblocks-json`, callers may convert + receipt-enriched indexed JSON containing `payload_id`, `index`, an index-zero + `base`/`static` header, `diff.transactions`, and exact + `metadata.receipts`. JSON-RPC envelopes, receipt-less previews, and binary SSZ + require separate adapters. The core deliberately does not own the source + socket, authentication, liveness timeout, raw-frame receive queue, retry, + backoff, rate limit, or provider rotation. The optional standardized-update + handoff queue is bounded and exposes backpressure, but does not make any of + those lifecycle decisions. A caller must forward `reset()`'s + invalidation before retrying after disconnect, replacement, or an untrusted + application-data error. Schema drift is therefore an application acceptance + failure, never permission to synthesize missing receipts or poll HTTP. Queue + admission is distinct from subscriber acceptance: awaited sends return the + subscriber verdict, while non-blocking sends return an acknowledgement receipt. + A rejection requires caller-owned generation revocation/reconnect; local + subscriber capacity rejection does not permanently quarantine the endpoint. +- **Speculative runtime state requires exact canonical lineage.** A + pre-confirmed batch is accepted only after the runtime has adopted a canonical + coverage head and only when the preview is its exact numbered child with the + matching parent hash. Applications must establish the canonical baseline + before enabling speculative delivery; missing or stale lineage fails closed + and revokes the active overlay. - **Recent toolchain.** MSRV 1.90 and edition 2024 are intentional and CI-enforced; consumers on older toolchains are not supported. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c33ed1b..48c754a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -627,11 +627,14 @@ acceptance contract in the spec (`tests/liveness_*`). tracked in `docs/KNOWN_ISSUES.md`). 3. **Transport depth.** The live `AlloySubscriber` ships log/block/pending-hash subscriptions, exponential-backoff reconnect, `get_logs` backfill, and - journaled parent-hash reorg recovery. The remaining transport gaps are full - block bodies, full pending-transaction hydration (today only pending-tx - hashes), and non-log historical backfill. Log interests can request - owner-scoped `get_logs` backfill from a block anchor. Remaining gaps are - tracked in `docs/KNOWN_ISSUES.md`. + journaled parent-hash reorg recovery. The default-off + `raw-flashblocks-json` feature also converts one receipt-enriched indexed + JSON Flashblocks profile while deliberately leaving its socket lifecycle to + the application. The remaining transport gaps are full block bodies, full + pending-transaction hydration (today only pending-tx hashes), non-log + historical backfill, and additional raw Flashblocks wire profiles. Log + interests can request owner-scoped `get_logs` backfill from a block anchor. + Remaining gaps are tracked in `docs/KNOWN_ISSUES.md`. 4. **Snapshot consistency point in continuous ingestion.** Closed in 0.2.0: `EvmCache::snapshot_generation()` is the crate-provided generation guard — read it around `snapshot()` and re-snapshot when it moved, so simulations diff --git a/docs/raw-json-flashblocks-acceptance.md b/docs/raw-json-flashblocks-acceptance.md new file mode 100644 index 0000000..d001622 --- /dev/null +++ b/docs/raw-json-flashblocks-acceptance.md @@ -0,0 +1,114 @@ +# Raw JSON Flashblocks acceptance + +This document records point-in-time evidence for the optional, +transport-independent receipt-enriched JSON adapter. It is not a provider +compatibility promise. Applications must requalify the exact endpoint and wire +profile they intend to use. + +## Deterministic boundary + +The default-off `raw-flashblocks-json` feature is exercised with one-index, +two-index, replacement, invalidation, malformed, oversized, discontinuous, +duplicate-transaction, receipt-incomplete, stale-generation, bounded-channel, +duplicate-receipt-key, channel-closure, subscriber-rejection recovery, +owner-replacement invalidation, and canonical-reconciliation cases. Direct and +bounded-channel regressions +cover index-zero starts, exact progression, same-index conflict, stable base +identity, cumulative transaction prefixes, and delta-log membership. Runtime +tests prove exact canonical-successor lineage, stale-replay rejection, immediate +canonical restoration, preferred-mode fallback, and required-mode failure for +both channel closure and queued malformed updates. The live probe performs the +zero-Flashblocks-RPC preflight and rejects a canonical chain-id mismatch before +starting its observation window. + +The public package check separately verifies that enabling only +`raw-flashblocks-json` does not add a socket transport to the library dependency +surface. The live example's WebSocket client is a development dependency and +the example is excluded unless both raw conversion and reactive WebSocket +features are selected. + +## Live source-to-subscriber control window + +On 2026-08-06, the optimized +`raw_json_flashblocks_subscriber_acceptance` example connected a passive raw +source to `RawJsonFlashblocksAdapter` and `AlloySubscriber` while an independent +canonical WebSocket subscribed to matching swap logs. It sent no application +message, issued no HTTP request, and intentionally implemented no retry or +backoff policy. + +The run stopped after 100 exact raw/canonical pairs in 162.833 seconds: + +| Result | Value | +| --- | ---: | +| Raw swap logs | 102 | +| Canonical swap logs | 100 | +| Exact pairs | 100 | +| Mature raw records unmatched | 0 | +| Closing-edge raw records unmatched | 2 | +| Interior canonical records unmatched | 0 | +| Content mismatches | 0 | +| Raw duplicate identities | 0 | +| Canonical duplicate identities | 0 | +| Raw-first pairs | 100 | +| Canonical-first pairs | 0 | + +Raw notification lead was 1,548.368 ms p50, 2,042.045 ms p95, 2,044.087 ms +p99, and 2,044.089 ms maximum. The subscriber processed 81 canonical heads and +81 normal canonical-overlay invalidations. Two unmatched raw records were at +the closing observation boundary and were not counted as mature misses. + +This proves that the observed receipt-enriched indexed profile can be converted +into the existing standardized subscriber path and correlated exactly with an +independent canonical stream. It does not prove availability, schema stability, +authentication behavior, retry policy, an application AMM integration, or +permission to execute against speculative state. Those remain consumer-owned +acceptance boundaries. + +## Resource-limit policy + +The library defaults are deliberately broad compatibility ceilings: 16 MiB per +frame, 64 indexed deltas, 50,000 cumulative transactions, and 200,000 cumulative +logs. They bound untrusted allocation and parsing, but they are not latency +targets and should not be copied blindly into an execution application. + +For the observed indexed OP profile, the downstream BIFI dry-run uses an +explicit 4 MiB frame, 16-index, 10,000-transaction, and 40,000-log profile. That +is application policy rather than a new wire-format promise. A consumer should +measure its own source distributions, retain reviewed headroom, and reconnect +or rotate the speculative source when a limit is exceeded. Canonical processing +must remain independent in preferred mode. + +A 4,108,968-byte stress frame just below that application byte limit, containing +4,500 transactions and 9,000 logs, measured 9.071 milliseconds (8.972–9.242 +milliseconds Criterion interval) and 431.98 MiB/s on the Apple M1 Pro release +build on 2026-08-07. One of 20 samples was classified as a high mild outlier and +one as a high severe outlier. +The parsing stage precedes downstream decision timing and remains part of +end-to-end signal latency; the count limits independently reject more +allocation-heavy shapes that fit under the byte ceiling. + +## Local conversion benchmark + +On an Apple M1 Pro release build, the checked-in Criterion workload measured: + +| Workload | Mean | +| --- | ---: | +| Initial indexed snapshot | 4.814 microseconds | +| Next cumulative index | 3.440 microseconds | +| 250 transactions / 500 logs | 459.46 microseconds | +| Bounded standardized-update queue admission | 9.496 microseconds | + +The scaled conversion processed approximately 217.36 MiB/s. The handoff number +measures non-blocking queue admission only; subscriber validation is separately +acknowledged by the API and intentionally excluded. These local measurements +do not include network delivery, downstream cache application, AMM quoting, or +canonical inclusion. + +The checked-in suite also builds a 15,521,468-byte frame containing 17,000 +transactions and 34,000 logs, below every default count ceiling and close to the +16 MiB frame ceiling. On the same Apple M1 Pro in a release build on 2026-08-07, +its Criterion point estimate was 39.005 milliseconds (38.082–40.312 +milliseconds Criterion interval), or 379.50 MiB/s, with two high severe +outliers among 20 samples. Its purpose is to expose the bounded worst-case +parsing cost before release. Production consumers should select smaller limits +unless their measured source requires the broader compatibility envelope. diff --git a/examples/raw_json_flashblocks_subscriber_acceptance.rs b/examples/raw_json_flashblocks_subscriber_acceptance.rs new file mode 100644 index 0000000..8608829 --- /dev/null +++ b/examples/raw_json_flashblocks_subscriber_acceptance.rs @@ -0,0 +1,477 @@ +//! Compare a caller-owned raw JSON Flashblocks socket with canonical WebSocket +//! logs through the standard `AlloySubscriber` delivery path. +//! +//! This opt-in acceptance probe is chain-neutral: the caller supplies both +//! endpoints and the chain id. The raw lane sends no application message and +//! performs no JSON-RPC request. It connects once, converts receipt-enriched +//! indexed JSON frames, and forwards standardized updates through the bounded +//! subscriber channel. Socket retry and backoff deliberately remain outside +//! this example and the library. + +use std::{ + collections::{HashMap, HashSet}, + time::Duration, +}; + +use alloy_consensus::BlockHeader as _; +use alloy_network::Ethereum; +use alloy_primitives::{B256, keccak256}; +use alloy_provider::{ProviderBuilder, WsConnect}; +use alloy_rpc_types_eth::{Filter, Log}; +use anyhow::{Context as _, Result, bail, ensure}; +use evm_fork_cache::reactive::{ + AlloySubscriber, BlockInterest, ChainStatus, EventSubscriber, FlashblockUpdateSender, + LogInterest, PreconfirmationMode, ProviderRef, RawJsonFlashblocksAdapter, ReactiveInput, + ReactiveInterest, SubscriberConfig, SubscriberInputScope, SubscriberMode, +}; +use futures::{SinkExt as _, StreamExt as _}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +struct LogKey { + transaction_hash: B256, + log_index: u64, +} + +#[derive(Clone)] +struct TimedLog { + observed_at: tokio::time::Instant, + log: Log, +} + +#[derive(Default)] +struct Stats { + raw_logs: u64, + canonical_logs: u64, + canonical_heads: u64, + invalidations: u64, + raw_duplicates: u64, + canonical_duplicates: u64, + content_mismatches: u64, + raw_first: u64, + canonical_first: u64, + simultaneous: u64, + raw_first_lead_micros: Vec, + absolute_correlation_micros: Vec, +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + install_rustls_provider(); + let raw_url = std::env::var("RAW_FLASHBLOCKS_WS_URL") + .context("RAW_FLASHBLOCKS_WS_URL must name a receipt-enriched raw WebSocket")?; + let canonical_url = std::env::var("CANONICAL_WS_URL") + .context("CANONICAL_WS_URL must name an independent canonical WebSocket")?; + let chain_id = env_u64("FLASHBLOCKS_CHAIN_ID", 10)?; + let run_seconds = env_u64("FLASHBLOCKS_ACCEPTANCE_SECONDS", 300)?; + let target_pairs = env_usize("FLASHBLOCKS_ACCEPTANCE_TARGET_PAIRS", 100)?; + ensure!(run_seconds > 0, "acceptance window must be nonzero"); + ensure!(target_pairs > 0, "target pair count must be nonzero"); + + let provider = ProviderBuilder::new() + .connect_ws(WsConnect::new(canonical_url)) + .await + .context("connect canonical WebSocket")?; + let swap_topics = [ + keccak256(b"Swap(address,address,int256,int256,uint160,uint128,int24)"), + keccak256(b"Swap(address,uint256,uint256,uint256,uint256,address)"), + ]; + let source = ProviderRef::new("raw-json-acceptance", 1); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + hydrate_pending_transactions: false, + verify_log_block_context: false, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .context("configure external Flashblocks source")?; + let updates = subscriber + .open_external_flashblock_update_channel(1_024) + .context("open bounded external update channel")?; + subscriber + .register_interests(&[ + ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().event_signature(swap_topics.to_vec()), + local_matcher: None, + route_key: None, + }), + ReactiveInterest::Blocks(BlockInterest::default()), + ]) + .await + .context("register canonical swap and head interests")?; + subscriber + .establish_flashblocks_preflight(chain_id) + .await + .context("verify canonical chain identity and external Flashblocks topology")?; + + let mut raw_task = tokio::spawn(forward_raw_frames(raw_url, source, updates)); + let started = tokio::time::Instant::now(); + let deadline = started + Duration::from_secs(run_seconds); + let mut raw_pending = HashMap::::new(); + let mut canonical_pending = HashMap::::new(); + let mut matched = HashSet::::new(); + let mut emitters = HashMap::new(); + let mut stats = Stats::default(); + let mut first_raw_block = None::; + let mut latest_canonical_head = None::; + + while matched.len() < target_pairs { + let batch = tokio::select! { + raw_result = &mut raw_task => { + raw_result.context("raw source task failed")??; + bail!("raw source ended before acceptance completed"); + } + result = tokio::time::timeout_at(deadline, subscriber.next_scoped_batch()) => { + match result { + Err(_) => break, + Ok(Ok(Some(batch))) => batch, + Ok(Ok(None)) => bail!("subscriber ended before acceptance completed"), + Ok(Err(error)) => return Err(error).context("poll standardized subscriber output"), + } + } + }; + if batch.preconfirmation_invalidated() { + stats.invalidations = stats.invalidations.saturating_add(1); + } + for record in batch.records() { + match &record.record().input { + ReactiveInput::BlockHeader(header) if record.scope().is_canonical() => { + stats.canonical_heads = stats.canonical_heads.saturating_add(1); + latest_canonical_head = Some( + latest_canonical_head + .map_or(header.number(), |current| current.max(header.number())), + ); + } + ReactiveInput::Log(log) => { + let Some(key) = log_key(log) else { + stats.content_mismatches = stats.content_mismatches.saturating_add(1); + continue; + }; + let now = tokio::time::Instant::now(); + match record.scope() { + SubscriberInputScope::Preconfirmed => { + ensure!( + matches!( + record.record().context.chain_status, + ChainStatus::Preconfirmed { .. } + ), + "speculative log lacked preconfirmation provenance" + ); + if let Some(block_number) = log.block_number { + first_raw_block = Some( + first_raw_block + .map_or(block_number, |first| first.min(block_number)), + ); + } + *emitters.entry(log.address()).or_insert(0_u64) += 1; + observe_raw( + key, + log.clone(), + now, + &mut raw_pending, + &mut canonical_pending, + &mut matched, + &mut stats, + ); + } + scope if scope.is_canonical() => { + ensure!( + matches!( + record.record().context.chain_status, + ChainStatus::Included { .. } + ), + "canonical log lacked included provenance" + ); + observe_canonical( + key, + log.clone(), + now, + &mut raw_pending, + &mut canonical_pending, + &mut matched, + &mut stats, + ); + } + _ => {} + } + } + _ => {} + } + } + } + + raw_task.abort(); + let _ = raw_task.await; + stats.raw_first_lead_micros.sort_unstable(); + stats.absolute_correlation_micros.sort_unstable(); + let mut ranked_emitters = emitters.into_iter().collect::>(); + ranked_emitters.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.1)); + let matched_count = matched.len(); + let latest_head = latest_canonical_head.unwrap_or_default(); + let first_raw = first_raw_block.unwrap_or(u64::MAX); + let matured_raw_unmatched = raw_pending + .values() + .filter(|entry| { + entry + .log + .block_number + .is_some_and(|number| number < latest_head) + }) + .count(); + let edge_raw_unmatched = raw_pending.len().saturating_sub(matured_raw_unmatched); + let interior_canonical_unmatched = canonical_pending + .values() + .filter(|entry| { + entry + .log + .block_number + .is_some_and(|number| number >= first_raw) + }) + .count(); + let edge_canonical_unmatched = canonical_pending + .len() + .saturating_sub(interior_canonical_unmatched); + let reconciliation_bps = u64::try_from(matched_count) + .unwrap_or(u64::MAX) + .saturating_mul(10_000) + .checked_div( + u64::try_from(matched_count.saturating_add(matured_raw_unmatched)).unwrap_or(u64::MAX), + ) + .unwrap_or_default(); + println!( + "elapsed_ms={} chain_id={} raw_logs={} canonical_logs={} paired_logs={} reconciliation_bps={} raw_first={} canonical_first={} simultaneous={} canonical_heads={} latest_canonical_head={} overlay_invalidations={} raw_duplicates={} canonical_duplicates={} content_mismatches={} matured_raw_unmatched={} edge_raw_unmatched={} interior_canonical_unmatched={} edge_canonical_unmatched={} raw_lead_p50_ms={:.3} raw_lead_p95_ms={:.3} raw_lead_p99_ms={:.3} raw_lead_max_ms={:.3} correlation_p95_ms={:.3}", + started.elapsed().as_millis(), + chain_id, + stats.raw_logs, + stats.canonical_logs, + matched_count, + reconciliation_bps, + stats.raw_first, + stats.canonical_first, + stats.simultaneous, + stats.canonical_heads, + latest_head, + stats.invalidations, + stats.raw_duplicates, + stats.canonical_duplicates, + stats.content_mismatches, + matured_raw_unmatched, + edge_raw_unmatched, + interior_canonical_unmatched, + edge_canonical_unmatched, + percentile_millis(&stats.raw_first_lead_micros, 50), + percentile_millis(&stats.raw_first_lead_micros, 95), + percentile_millis(&stats.raw_first_lead_micros, 99), + percentile_millis(&stats.raw_first_lead_micros, 100), + percentile_millis(&stats.absolute_correlation_micros, 95), + ); + for (rank, (address, count)) in ranked_emitters.into_iter().take(5).enumerate() { + println!( + "raw_emitter_rank={} address={address} logs={count}", + rank + 1 + ); + } + + ensure!( + matched_count >= target_pairs, + "subscriber did not reconcile the target speculative/canonical swap pairs" + ); + ensure!( + stats.canonical_heads >= 2, + "canonical head delivery was not continuous" + ); + ensure!(stats.content_mismatches == 0, "paired log content differed"); + ensure!( + stats.raw_duplicates == 0, + "speculative delivery duplicated a swap" + ); + ensure!( + stats.canonical_duplicates == 0, + "canonical delivery duplicated a swap" + ); + ensure!( + stats.raw_first > stats.canonical_first, + "raw delivery was not usually first" + ); + ensure!( + reconciliation_bps >= 9_900, + "fewer than 99% of matured raw swaps reconciled canonically" + ); + ensure!( + interior_canonical_unmatched == 0, + "the raw stream missed an interior canonical swap" + ); + Ok(()) +} + +async fn forward_raw_frames( + url: String, + source: ProviderRef, + updates: FlashblockUpdateSender, +) -> Result<()> { + let (mut socket, _) = connect_async(url).await.context("connect raw WebSocket")?; + let mut adapter = RawJsonFlashblocksAdapter::new(source); + while let Some(message) = socket.next().await { + let message = message.context("read raw WebSocket frame")?; + let decoded = match message { + Message::Text(text) => Some(adapter.ingest_json(text.as_bytes())), + Message::Binary(bytes) => Some(adapter.ingest_json(bytes.as_ref())), + Message::Ping(_) | Message::Pong(_) => { + socket + .flush() + .await + .context("flush WebSocket control response")?; + None + } + Message::Close(_) => bail!("raw WebSocket closed"), + Message::Frame(_) => None, + }; + let Some(decoded) = decoded else { + continue; + }; + if let Some(update) = decoded.context("normalize raw Flashblocks frame")? { + updates + .send(update) + .await + .context("forward standardized Flashblocks update")?; + } + } + bail!("raw WebSocket ended") +} + +fn observe_raw( + key: LogKey, + log: Log, + now: tokio::time::Instant, + raw_pending: &mut HashMap, + canonical_pending: &mut HashMap, + matched: &mut HashSet, + stats: &mut Stats, +) { + stats.raw_logs = stats.raw_logs.saturating_add(1); + if matched.contains(&key) || raw_pending.contains_key(&key) { + stats.raw_duplicates = stats.raw_duplicates.saturating_add(1); + return; + } + let raw = TimedLog { + observed_at: now, + log, + }; + if let Some(canonical) = canonical_pending.remove(&key) { + record_match(key, raw, canonical, matched, stats); + } else { + raw_pending.insert(key, raw); + } +} + +fn observe_canonical( + key: LogKey, + log: Log, + now: tokio::time::Instant, + raw_pending: &mut HashMap, + canonical_pending: &mut HashMap, + matched: &mut HashSet, + stats: &mut Stats, +) { + stats.canonical_logs = stats.canonical_logs.saturating_add(1); + if matched.contains(&key) || canonical_pending.contains_key(&key) { + stats.canonical_duplicates = stats.canonical_duplicates.saturating_add(1); + return; + } + let canonical = TimedLog { + observed_at: now, + log, + }; + if let Some(raw) = raw_pending.remove(&key) { + record_match(key, raw, canonical, matched, stats); + } else { + canonical_pending.insert(key, canonical); + } +} + +fn record_match( + key: LogKey, + raw: TimedLog, + canonical: TimedLog, + matched: &mut HashSet, + stats: &mut Stats, +) { + if !equivalent_log_content(&raw.log, &canonical.log) { + stats.content_mismatches = stats.content_mismatches.saturating_add(1); + } + let correlation = if canonical.observed_at > raw.observed_at { + let lead = canonical + .observed_at + .duration_since(raw.observed_at) + .as_micros() as u64; + stats.raw_first = stats.raw_first.saturating_add(1); + stats.raw_first_lead_micros.push(lead); + lead + } else if raw.observed_at > canonical.observed_at { + stats.canonical_first = stats.canonical_first.saturating_add(1); + raw.observed_at + .duration_since(canonical.observed_at) + .as_micros() as u64 + } else { + stats.simultaneous = stats.simultaneous.saturating_add(1); + 0 + }; + stats.absolute_correlation_micros.push(correlation); + matched.insert(key); +} + +fn equivalent_log_content(raw: &Log, canonical: &Log) -> bool { + raw.address() == canonical.address() + && raw.topics() == canonical.topics() + && raw.inner.data.data == canonical.inner.data.data + && raw.block_number == canonical.block_number + && raw.transaction_hash == canonical.transaction_hash + && raw.transaction_index == canonical.transaction_index + && raw.log_index == canonical.log_index + && !canonical.removed +} + +fn log_key(log: &Log) -> Option { + Some(LogKey { + transaction_hash: log.transaction_hash?, + log_index: log.log_index?, + }) +} + +fn env_u64(name: &str, default: u64) -> Result { + std::env::var(name).map_or(Ok(default), |value| { + value + .parse::() + .with_context(|| format!("parse {name} as an unsigned integer")) + }) +} + +fn env_usize(name: &str, default: usize) -> Result { + std::env::var(name).map_or(Ok(default), |value| { + value + .parse::() + .with_context(|| format!("parse {name} as an unsigned integer")) + }) +} + +fn percentile_millis(sorted_micros: &[u64], percentile: usize) -> f64 { + if sorted_micros.is_empty() { + return 0.0; + } + let index = sorted_micros + .len() + .saturating_mul(percentile) + .div_ceil(100) + .saturating_sub(1) + .min(sorted_micros.len() - 1); + sorted_micros[index] as f64 / 1_000.0 +} + +fn install_rustls_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} diff --git a/scripts/check-security-exceptions.sh b/scripts/check-security-exceptions.sh index 59079ad..5e6839c 100755 --- a/scripts/check-security-exceptions.sh +++ b/scripts/check-security-exceptions.sh @@ -159,7 +159,7 @@ bincode_graph="$({ } | sed -E 's# \(/[^)]*\)$##; s# \(\*\)$##')" expected_bincode_graph="$(printf '%s\n' \ '0bincode v1.3.3' \ - '1evm-fork-cache v0.4.0-alpha.2')" + '1evm-fork-cache v0.4.0-alpha.3')" if [[ "$bincode_graph" != "$expected_bincode_graph" ]]; then echo "The accepted bincode 1 compatibility scope changed." >&2 echo "Expected:" >&2 diff --git a/src/reactive/mod.rs b/src/reactive/mod.rs index 9d1b453..97949a0 100644 --- a/src/reactive/mod.rs +++ b/src/reactive/mod.rs @@ -62,6 +62,15 @@ use crate::{ state_update::{AccountPatch, PurgeScope, StateDiff, StateUpdate}, }; +#[cfg(feature = "raw-flashblocks-json")] +mod raw_json_flashblocks; +#[cfg(feature = "raw-flashblocks-json")] +pub use raw_json_flashblocks::{ + FlashblockInvalidation, FlashblockInvalidationReason, FlashblockSnapshot, FlashblockUpdate, + FlashblockUpdateAcknowledgement, FlashblockUpdateChannelError, FlashblockUpdateSender, + RawJsonFlashblocksAdapter, RawJsonFlashblocksError, RawJsonFlashblocksLimits, +}; + /// Input accepted by the reactive runtime. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ReactiveInput { @@ -208,6 +217,17 @@ impl FlashblockRef { } } + #[cfg(feature = "raw-flashblocks-json")] + fn same_base_identity(&self, other: &Self) -> bool { + self.block_number == other.block_number + && self.parent_hash == other.parent_hash + && self.timestamp == other.timestamp + && self.base_fee_per_gas == other.base_fee_per_gas + && self.beneficiary == other.beneficiary + && self.prevrandao == other.prevrandao + && self.gas_limit == other.gas_limit + } + fn is_cumulative_successor_of(&self, previous: &Self) -> bool { self.same_payload(previous) && self.transaction_hashes.len() >= previous.transaction_hashes.len() @@ -512,6 +532,94 @@ fn flashblock_content_hash(content: FlashblockContentCommitment<'_>) -> B256 { } } +#[cfg(feature = "raw-flashblocks-json")] +fn validate_standard_flashblock_snapshot( + snapshot: &FlashblockSnapshot, +) -> Result<(), SubscriberError> { + let flashblock = &snapshot.flashblock; + if flashblock.payload_id.is_none() || flashblock.index.is_none() { + return Err(SubscriberError::Provider( + "external Flashblock snapshot is missing its indexed payload identity".into(), + )); + } + let expected_content_hash = flashblock_content_hash(FlashblockContentCommitment { + provider: &flashblock.provider, + payload_id: flashblock.payload_id, + index: flashblock.index, + block_number: flashblock.block_number, + partial_block_hash: flashblock.partial_block_hash, + parent_hash: flashblock.parent_hash, + state_root: flashblock.state_root, + transactions_root: flashblock.transactions_root, + transaction_hashes: &flashblock.transaction_hashes, + timestamp: flashblock.timestamp, + base_fee_per_gas: flashblock.base_fee_per_gas, + beneficiary: flashblock.beneficiary, + prevrandao: flashblock.prevrandao, + gas_limit: flashblock.gas_limit, + }); + if flashblock.content_hash != expected_content_hash { + return Err(SubscriberError::Provider( + "external Flashblock content commitment is invalid".into(), + )); + } + + let mut transactions = HashSet::with_capacity(flashblock.transaction_hashes.len()); + if flashblock + .transaction_hashes + .iter() + .any(|hash| !transactions.insert(*hash)) + { + return Err(SubscriberError::Provider( + "external Flashblock cumulative transaction membership contains a duplicate hash" + .into(), + )); + } + + let mut log_ids = HashSet::with_capacity(snapshot.logs.len()); + for log in &snapshot.logs { + if log.removed || log.block_number != Some(flashblock.block_number) { + return Err(SubscriberError::Provider( + "external pre-confirmed log disagrees with its Flashblock block identity".into(), + )); + } + if log.block_hash != Some(flashblock.content_hash) { + return Err(SubscriberError::Provider( + "external pre-confirmed log is not bound to its Flashblock content commitment" + .into(), + )); + } + let transaction_hash = log.transaction_hash.ok_or_else(|| { + SubscriberError::Provider( + "external pre-confirmed log is missing its transaction hash".into(), + ) + })?; + let expected_transaction_index = flashblock + .transaction_index(&transaction_hash) + .ok_or_else(|| { + SubscriberError::Provider( + "external pre-confirmed log transaction is absent from the cumulative Flashblock" + .into(), + ) + })?; + if log.transaction_index != Some(expected_transaction_index) { + return Err(SubscriberError::Provider( + "external pre-confirmed log transaction index disagrees with cumulative membership" + .into(), + )); + } + let log_index = log.log_index.ok_or_else(|| { + SubscriberError::Provider("external pre-confirmed log is missing its log index".into()) + })?; + if !log_ids.insert((transaction_hash, log_index)) { + return Err(SubscriberError::Provider( + "external Flashblock snapshot contains a duplicate log identity".into(), + )); + } + } + Ok(()) +} + fn commit_optional_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) { match value { Some(value) => { @@ -4723,7 +4831,10 @@ impl ReactiveRuntime { /// /// Returns [`ReactiveError`] when records or controls are invalid, canonical /// continuity cannot be proven, a handler rejects input, or an effect cannot - /// be applied. Cache and canonical runtime state are restored before return. + /// be applied. A pre-confirmed batch additionally requires an adopted + /// canonical coverage head and must identify its exact child by number and + /// parent hash. Cache and canonical runtime state are restored before + /// return; a lineage failure revokes any active speculative branch. pub fn ingest_batch( &mut self, cache: &mut EvmCache, @@ -4831,6 +4942,28 @@ impl ReactiveRuntime { cache: &mut EvmCache, incoming: &FlashblockRef, ) -> Result<(), ReactiveError> { + let Some(canonical) = self.coverage_head else { + self.discard_preconfirmed_branch(cache); + return Err(ReactiveError::InvalidInputRecord { + message: "pre-confirmed state requires an exact canonical coverage baseline".into(), + }); + }; + if canonical.number.checked_add(1) != Some(incoming.block_number) { + self.discard_preconfirmed_branch(cache); + return Err(ReactiveError::InvalidInputRecord { + message: format!( + "pre-confirmed block {} is not the exact successor of canonical block {}", + incoming.block_number, canonical.number + ), + }); + } + if incoming.parent_hash != Some(canonical.hash) { + self.discard_preconfirmed_branch(cache); + return Err(ReactiveError::InvalidInputRecord { + message: "pre-confirmed block parent does not match the canonical coverage hash" + .into(), + }); + } if let Some(active) = self.preconfirmed_branch.as_ref() && active.flashblock.same_payload(incoming) { @@ -9848,6 +9981,9 @@ pub struct SubscriberConfig { /// Cadence for certifying sealed canonical heads while connected to a /// Flashblocks endpoint whose `newHeads` stream may contain partial heads. pub canonical_head_poll_interval: Duration, + /// Maximum time allowed for one provider request that certifies a + /// canonical head while Flashblocks are active. + pub canonical_head_request_timeout: Duration, /// Optimism pending-state sampling cadence. /// /// Base uses native `newFlashblocks` plus `pendingLogs`. Optimism providers @@ -9921,6 +10057,7 @@ impl Default for SubscriberConfig { Self { preconfirmations: PreconfirmationMode::Disabled, canonical_head_poll_interval: Duration::from_millis(500), + canonical_head_request_timeout: Duration::from_secs(3), flashblock_poll_interval: Duration::from_millis(250), max_consecutive_flashblock_poll_failures: 10, max_pending_transaction_receipts_per_tick: 32, @@ -9945,6 +10082,9 @@ pub enum FlashblocksDelivery { NativeSubscriptions, /// Generation-pinned `pending` block and log sampling. PendingStatePolling, + /// Standardized updates supplied by an application-managed transport. + #[cfg(feature = "raw-flashblocks-json")] + ExternalUpdates, } /// Request/response traffic issued by one Flashblocks subscriber generation. @@ -10033,10 +10173,11 @@ impl FlashblocksRpcMetrics { /// Successful initial Flashblocks endpoint preflight. /// /// This proves chain identity and either subscription acknowledgement for -/// Base's `newFlashblocks` plus every pool-filtered `pendingLogs` stream, or -/// method support for OP's bounded pending block/log sampler. Notification -/// liveness and an active-pool pending log remain acceptance-window checks: a -/// successful preflight alone must not qualify an endpoint for live trading. +/// Base's `newFlashblocks` plus every pool-filtered `pendingLogs` stream, method +/// support for OP's bounded pending block/log sampler, or the canonical stream +/// topology paired with an application-managed standardized source. +/// Notification liveness and active-interest coverage remain acceptance-window +/// checks: a successful preflight alone must not qualify a source for live use. #[derive(Clone, Debug, PartialEq, Eq)] pub struct FlashblocksPreflight { chain_id: u64, @@ -10053,8 +10194,11 @@ impl FlashblocksPreflight { self.chain_id } - /// Provider generation whose HTTP state and both WebSocket streams were - /// preflighted together. + /// Provider generation selected for speculative updates. + /// + /// Built-in profiles preflight this provider's coupled request/subscription + /// surfaces. External profiles retain caller-supplied provenance while the + /// application qualifies the supplemental socket separately. pub const fn provider(&self) -> &ProviderRef { &self.provider } @@ -10065,12 +10209,14 @@ impl FlashblocksPreflight { } /// Number of acknowledged pool-filtered `pendingLogs` subscriptions. + /// + /// This is zero for sampled and externally managed delivery profiles. pub const fn pending_log_subscriptions(&self) -> usize { self.pending_log_subscriptions } - /// Number of provider-facing pending-log filters covered by the native or - /// sampled delivery surface. + /// Number of provider-facing log filters whose interests must be covered by + /// the selected native, sampled, or external delivery surface. pub const fn pending_log_filters(&self) -> usize { self.pending_log_filters } @@ -12249,6 +12395,24 @@ type FlashblockReconnectFuture = Pin< /// `Ok(None)`. pub struct AlloySubscriber { provider: P, + /// Stable identity of an application-managed standardized Flashblock + /// update source. The application owns its transport and lifecycle. + #[cfg(feature = "raw-flashblocks-json")] + external_flashblocks_provider: Option, + /// Receiving half of the optional bounded application-to-subscriber queue. + #[cfg(feature = "raw-flashblocks-json")] + external_flashblock_updates: + Option>, + /// Whether an external update queue was opened for this subscriber. + #[cfg(feature = "raw-flashblocks-json")] + external_flashblock_update_channel_opened: bool, + /// Highest external generation rejected by subscriber-level validation. + #[cfg(feature = "raw-flashblocks-json")] + rejected_external_flashblock_generation: Option, + /// Last accepted externally standardized snapshot, retained so callers + /// cannot bypass indexed-payload continuity enforced by the raw adapter. + #[cfg(feature = "raw-flashblocks-json")] + last_external_flashblock_snapshot: Option, /// Optional request/response half of the same configured provider lease. /// OP Flashblocks pending reads use this transport when WebSocket JSON-RPC /// does not expose the provider's pending-state surface. @@ -12404,6 +12568,16 @@ impl AlloySubscriber { ensure_ring_crypto_provider(); Self { provider, + #[cfg(feature = "raw-flashblocks-json")] + external_flashblocks_provider: None, + #[cfg(feature = "raw-flashblocks-json")] + external_flashblock_updates: None, + #[cfg(feature = "raw-flashblocks-json")] + external_flashblock_update_channel_opened: false, + #[cfg(feature = "raw-flashblocks-json")] + rejected_external_flashblock_generation: None, + #[cfg(feature = "raw-flashblocks-json")] + last_external_flashblock_snapshot: None, flashblocks_state_provider: None, provider_ref: None, log_verification_provider: None, @@ -12466,6 +12640,113 @@ impl AlloySubscriber { self } + /// Select application-managed standardized Flashblock updates before + /// subscriber registration begins. + /// + /// This suppresses the subscriber's chain-specific native or pending-state + /// Flashblocks source. Canonical logs and block headers continue through the + /// configured subscriber transport. The application owns the raw socket, + /// control frames, bounded queue, timeout, retry, backoff, and provider + /// rotation, and passes decoded updates to + /// [`Self::ingest_flashblock_update`]. + /// + /// Call [`Self::ingest_flashblock_update`] directly while retaining mutable + /// subscriber ownership, or open a bounded handoff with + /// [`Self::open_external_flashblock_update_channel`] before moving the + /// subscriber into another runtime owner. + /// + /// This is deliberately a fallible construction-time configuration method, + /// not a live reconfiguration API. Replacing a source after canonical or + /// speculative processing begins requires a new subscriber so existing + /// streams and overlays cannot survive under ambiguous provider ownership. + /// + /// # Errors + /// + /// Returns [`SubscriberError::InvalidConfig`] when an external source was + /// already selected or subscriber registration, stream installation, or + /// event processing has begun. + #[cfg(feature = "raw-flashblocks-json")] + pub fn configure_external_flashblock_updates( + &mut self, + provider: ProviderRef, + ) -> Result<(), SubscriberError> { + if self.external_flashblocks_provider.is_some() { + return Err(SubscriberError::InvalidConfig( + "external Flashblock updates were already configured", + )); + } + if self.external_flashblock_update_channel_opened + || self.external_flashblock_updates.is_some() + || self.chain_id.is_some() + || !self.base_interests.is_empty() + || !self.owned_interests.is_empty() + || !self.interests.is_empty() + || !self.pending_records.is_empty() + || !self.pending_chain_controls.is_empty() + || !self.pending_backfills.is_empty() + || !matches!(self.state, AlloySubscriberState::Uninitialized) + { + return Err(SubscriberError::InvalidConfig( + "external Flashblock updates must be configured before subscriber registration", + )); + } + self.external_flashblocks_provider = Some(provider); + Ok(()) + } + + /// Open one bounded standardized-update queue and return its application handle. + /// + /// The queue is useful when the subscriber will be moved into a runtime + /// driver: the application retains the cloneable sender while the subscriber + /// continues to own all validation, speculative deduplication, and canonical + /// reconciliation. Opening a queue does not create a socket or background + /// task, and does not implement retry or backoff. Awaited sends complete + /// only after subscriber validation; non-blocking sends return an explicit + /// acknowledgement receipt. + /// + /// # Errors + /// + /// Returns [`SubscriberError::InvalidConfig`] if external updates were not + /// selected first, `capacity` is zero, or a queue was already opened. + #[cfg(feature = "raw-flashblocks-json")] + pub fn open_external_flashblock_update_channel( + &mut self, + capacity: usize, + ) -> Result { + if capacity == 0 { + return Err(SubscriberError::InvalidConfig( + "external Flashblock update channel capacity must be greater than zero", + )); + } + let provider = self.external_flashblocks_provider.clone().ok_or( + SubscriberError::InvalidConfig( + "external Flashblock update channel requires configure_external_flashblock_updates", + ), + )?; + if self.external_flashblock_update_channel_opened { + return Err(SubscriberError::InvalidConfig( + "external Flashblock update channel was already opened", + )); + } + let (sender, receiver) = + raw_json_flashblocks::flashblock_update_channel(provider, capacity); + self.external_flashblock_updates = Some(receiver); + self.external_flashblock_update_channel_opened = true; + self.sources_dirty = true; + Ok(sender) + } + + fn uses_external_flashblock_updates(&self) -> bool { + #[cfg(feature = "raw-flashblocks-json")] + { + self.external_flashblocks_provider.is_some() + } + #[cfg(not(feature = "raw-flashblocks-json"))] + { + false + } + } + /// Pair the subscriber's event transport with the request/response /// transport for the same configured provider ID and generation. /// @@ -13139,12 +13420,22 @@ impl AlloySubscriber { // delivery after the cache snapshot, so reset all stale delivery and // dedupe state from the prior topology before publishing the exact // replacement plus its global historical work. + let revoke_preconfirmation = self.latest_preconfirmation.is_some() + || self.pending_preconfirmation_invalidation + || self.pending_records.iter().any(|record| { + record.scope == SubscriberInputScope::Preconfirmed + || matches!( + &record.record.context.chain_status, + ChainStatus::Preconfirmed { .. } + ) + }); self.base_interests.clear(); self.owned_interests = next_owned; self.interests = next_registered; self.reset_delivery_state(); + self.pending_preconfirmation_invalidation = revoke_preconfirmation; self.pending_backfills = replacement_backfills; - self.state = AlloySubscriberState::Uninitialized; + self.reset_stream_topology(); Ok(()) } @@ -13607,6 +13898,8 @@ impl AlloySubscriber { | SubscriberStreamSource::PubSubPendingHashes | SubscriberStreamSource::PubSubBlockHeaders | SubscriberStreamSource::PollingPendingHashes => {} + #[cfg(feature = "raw-flashblocks-json")] + SubscriberStreamSource::ExternalFlashblockUpdates => {} } } } @@ -13692,6 +13985,28 @@ impl AlloySubscriber { self.reset_flashblock_tracking(); } + fn reset_stream_topology(&mut self) { + #[cfg(feature = "raw-flashblocks-json")] + let external = match &mut self.state { + AlloySubscriberState::Active(streams) => streams + .entries + .iter() + .position(|entry| entry.source.is_external_flashblocks()) + .map(|index| streams.entries.remove(index)), + AlloySubscriberState::Uninitialized | AlloySubscriberState::Empty => None, + }; + + #[cfg(feature = "raw-flashblocks-json")] + if let Some(external) = external { + let mut streams = SubscriberStreams::new(); + streams.entries.push(external); + self.state = AlloySubscriberState::Active(streams); + return; + } + + self.state = AlloySubscriberState::Uninitialized; + } + fn reset_flashblock_tracking(&mut self) { self.base_flashblock_header = None; self.base_flashblock_transactions = None; @@ -13700,6 +14015,10 @@ impl AlloySubscriber { self.preconfirmed_seen_logs.clear(); self.preconfirmed_receipted_transactions.clear(); self.preconfirmed_unavailable_receipts.clear(); + #[cfg(feature = "raw-flashblocks-json")] + { + self.last_external_flashblock_snapshot = None; + } self.consecutive_flashblock_poll_failures = 0; } @@ -13948,15 +14267,25 @@ enum SubscriberTransport { #[derive(Clone, Debug)] enum SubscriberStreamSource { - PubSubLog { id: usize, filter: Filter }, - BasePendingLog { id: usize, filter: Filter }, + PubSubLog { + id: usize, + filter: Filter, + }, + BasePendingLog { + id: usize, + filter: Filter, + }, BaseFlashblocks, OpPendingFlashblocks, CanonicalHeadPolling, PubSubPendingHashes, PubSubBlockHeaders, - PollingLog { filter: Filter }, + PollingLog { + filter: Filter, + }, PollingPendingHashes, + #[cfg(feature = "raw-flashblocks-json")] + ExternalFlashblockUpdates, } impl SubscriberStreamSource { @@ -13971,6 +14300,8 @@ impl SubscriberStreamSource { Self::PubSubBlockHeaders => "pubsub block header", Self::PollingLog { .. } => "polling log", Self::PollingPendingHashes => "polling pending transaction hash", + #[cfg(feature = "raw-flashblocks-json")] + Self::ExternalFlashblockUpdates => "external standardized Flashblock update", } } @@ -14009,9 +14340,22 @@ impl SubscriberStreamSource { | (Self::PubSubPendingHashes, Self::PubSubPendingHashes) | (Self::PubSubBlockHeaders, Self::PubSubBlockHeaders) | (Self::PollingPendingHashes, Self::PollingPendingHashes) => true, + #[cfg(feature = "raw-flashblocks-json")] + (Self::ExternalFlashblockUpdates, Self::ExternalFlashblockUpdates) => true, _ => false, } } + + fn is_external_flashblocks(&self) -> bool { + #[cfg(feature = "raw-flashblocks-json")] + { + matches!(self, Self::ExternalFlashblockUpdates) + } + #[cfg(not(feature = "raw-flashblocks-json"))] + { + false + } + } } #[allow(dead_code)] @@ -14041,6 +14385,8 @@ enum SubscriberEvent { }, FlashblockInvalidated, FlashblockObserved, + #[cfg(feature = "raw-flashblocks-json")] + ExternalFlashblockUpdate(raw_json_flashblocks::QueuedFlashblockUpdate), StreamTerminated(SubscriberStreamSource), } @@ -14202,8 +14548,9 @@ where capabilities.push(SubscriberCapability::BlockHeaders); } if self.config.preconfirmations != PreconfirmationMode::Disabled - && self.provider_ref.is_some() - && self.chain_id.and_then(flashblocks_adapter).is_some() + && (self.uses_external_flashblock_updates() + || (self.provider_ref.is_some() + && self.chain_id.and_then(flashblocks_adapter).is_some())) { capabilities.push(SubscriberCapability::Preconfirmations); } @@ -14227,7 +14574,7 @@ where self.owned_interests.clear(); self.rebuild_registered_interests(); self.reset_delivery_state(); - self.state = AlloySubscriberState::Uninitialized; + self.reset_stream_topology(); Ok(()) }) } @@ -14248,16 +14595,23 @@ where N: Network + 'static, N::HeaderResponse: Send + 'static, { - /// Validate one pinned OP Stack provider generation and establish its - /// chain-specific Flashblocks surface. + /// Validate one configured provider generation and establish its selected + /// Flashblocks delivery surface. /// /// The caller must register at least one active log interest first. The - /// method requires a matching chain id and stable [`ProviderRef`]. Base - /// additionally requires pubsub, `newFlashblocks`, and one `pendingLogs` - /// acknowledgement per planned provider filter. Optimism probes the - /// bounded pending block/log/receipt surface. `op_supportedCapabilities` - /// is queried opportunistically and retained as opaque evidence because - /// provider implementations do not expose a uniform capability vocabulary. + /// built-in profiles require a matching chain id and stable [`ProviderRef`]. + /// Base additionally requires pubsub, `newFlashblocks`, and one + /// `pendingLogs` acknowledgement per planned provider filter. Optimism + /// probes the bounded pending block/log/receipt surface. + /// `op_supportedCapabilities` is queried opportunistically and retained as + /// opaque evidence because provider implementations do not expose a uniform + /// capability vocabulary. + /// + /// With `raw-flashblocks-json` and + /// [`Self::configure_external_flashblock_updates`], preflight instead verifies + /// the canonical subscriber chain and installed canonical stream topology. + /// The application owns supplemental-source qualification, and this method + /// performs no Flashblocks request/response calls for that profile. /// /// A successful return is deliberately not a liveness qualification. The /// acceptance window must still observe a Flashblock whose pending state @@ -14289,6 +14643,18 @@ where }); } self.validate_flashblocks_setup()?; + #[cfg(feature = "raw-flashblocks-json")] + if let Some(provider) = self.external_flashblocks_provider.clone() { + self.ensure_streams().await?; + return Ok(FlashblocksPreflight { + chain_id, + provider, + delivery: FlashblocksDelivery::ExternalUpdates, + pending_log_subscriptions: 0, + pending_log_filters: self.log_stream_filters().len(), + advertised_capabilities: None, + }); + } let adapter = flashblocks_adapter(chain_id).ok_or(SubscriberError::Unsupported( "Flashblocks are currently implemented for Base and OP chains", ))?; @@ -14400,6 +14766,180 @@ where }) } + /// Ingest one standardized update from an application-managed source. + /// + /// This method is synchronous and performs no provider I/O. The update is + /// validated against the configured source identity, normalized through + /// the same preconfirmation deduplication used by provider subscriptions, + /// and queued for ordinary [`EventSubscriber`] delivery. Stale provider + /// generations and stale invalidations cannot revoke newer speculative + /// state. Indexed snapshots must begin at zero, advance exactly one index at + /// a time, preserve their base identity and cumulative transaction prefix, + /// and bind delta logs only to newly appended transactions. + #[cfg(feature = "raw-flashblocks-json")] + pub fn ingest_flashblock_update( + &mut self, + update: FlashblockUpdate, + ) -> Result<(), SubscriberError> { + validate_subscriber_config(&self.config)?; + self.validate_flashblocks_setup()?; + let configured = + self.external_flashblocks_provider + .as_ref() + .ok_or(SubscriberError::InvalidConfig( + "standardized Flashblock updates require configure_external_flashblock_updates", + ))?; + + match update { + FlashblockUpdate::Snapshot(batch) => { + if batch.flashblock.provider.endpoint != configured.endpoint { + return Err(SubscriberError::Provider( + "external Flashblock update came from an unexpected provider endpoint" + .into(), + )); + } + if batch.flashblock.provider.generation < configured.generation + || self.latest_preconfirmation.as_ref().is_some_and(|latest| { + latest.provider.endpoint == batch.flashblock.provider.endpoint + && latest.provider.generation > batch.flashblock.provider.generation + }) + { + return Ok(()); + } + if self + .rejected_external_flashblock_generation + .is_some_and(|rejected| batch.flashblock.provider.generation <= rejected) + { + return Err(SubscriberError::Provider( + "external Flashblock provider generation was previously rejected".into(), + )); + } + validate_standard_flashblock_snapshot(&batch)?; + if self.validate_external_flashblock_sequence(&batch)? { + return Ok(()); + } + let required = self.pending_record_count().saturating_add(batch.logs.len()); + if required > self.config.max_pending_records { + self.invalidate_preconfirmation_snapshot(); + self.last_external_flashblock_snapshot = None; + return Err(SubscriberError::ResourceExhausted(format!( + "external preconfirmation records require {required} pending records, above the configured limit of {}", + self.config.max_pending_records + ))); + } + let accepted_snapshot = (*batch).clone(); + let FlashblockSnapshot { flashblock, logs } = *batch; + let logs = self.filter_preconfirmed_logs(&flashblock, logs)?; + self.last_external_flashblock_snapshot = Some(accepted_snapshot); + if let Some(provider) = self.external_flashblocks_provider.as_mut() { + provider.generation = provider.generation.max(flashblock.provider.generation); + } + if !logs.is_empty() { + self.enqueue_event(SubscriberEvent::PreconfirmedLogs { flashblock, logs }); + } + } + FlashblockUpdate::Invalidated(invalidation) => { + if invalidation.provider.endpoint != configured.endpoint { + return Err(SubscriberError::Provider( + "external Flashblock invalidation came from an unexpected provider endpoint" + .into(), + )); + } + if self.latest_preconfirmation.as_ref().is_some_and(|latest| { + latest.provider == invalidation.provider + && latest.payload_id == Some(invalidation.payload_id) + }) { + self.invalidate_preconfirmation_snapshot(); + self.last_external_flashblock_snapshot = None; + } + } + } + Ok(()) + } + + #[cfg(feature = "raw-flashblocks-json")] + fn validate_external_flashblock_sequence( + &self, + snapshot: &FlashblockSnapshot, + ) -> Result { + let Some(previous) = self.last_external_flashblock_snapshot.as_ref() else { + if snapshot.flashblock.index != Some(0) { + return Err(SubscriberError::Provider( + "external Flashblock payload generation must begin at index zero".into(), + )); + } + return Ok(false); + }; + + if previous.flashblock.provider == snapshot.flashblock.provider + && previous.flashblock.payload_id == snapshot.flashblock.payload_id + { + let previous_index = previous + .flashblock + .index + .expect("validated indexed snapshot"); + let current_index = snapshot + .flashblock + .index + .expect("validated indexed snapshot"); + if current_index == previous_index { + if previous == snapshot { + return Ok(true); + } + return Err(SubscriberError::Provider( + "external Flashblock repeated the same index with conflicting content".into(), + )); + } + if current_index < previous_index { + return Err(SubscriberError::Provider(format!( + "external Flashblock index regressed from {previous_index} to {current_index}" + ))); + } + if current_index > previous_index.saturating_add(1) { + return Err(SubscriberError::Provider(format!( + "external Flashblock index skipped from {previous_index} to {current_index}" + ))); + } + if current_index == previous_index.saturating_add(1) + && !previous.flashblock.same_base_identity(&snapshot.flashblock) + { + return Err(SubscriberError::Provider( + "external Flashblock base identity changed within one payload generation" + .into(), + )); + } + if current_index == previous_index.saturating_add(1) + && !snapshot + .flashblock + .transaction_hashes + .starts_with(&previous.flashblock.transaction_hashes) + { + return Err(SubscriberError::Provider( + "external Flashblock cumulative transaction membership changed its prior prefix" + .into(), + )); + } + let prior_transaction_count = + u64::try_from(previous.flashblock.transaction_hashes.len()).unwrap_or(u64::MAX); + if current_index == previous_index.saturating_add(1) + && snapshot.logs.iter().any(|log| { + log.transaction_index + .is_some_and(|index| index < prior_transaction_count) + }) + { + return Err(SubscriberError::Provider( + "external Flashblock delta log does not belong to a newly appended transaction" + .into(), + )); + } + } else if snapshot.flashblock.index != Some(0) { + return Err(SubscriberError::Provider( + "external Flashblock payload generation must begin at index zero".into(), + )); + } + Ok(false) + } + async fn probe_pending_state(&mut self, filters: &[Filter]) -> Result<(), SubscriberError> { self.flashblocks_rpc_metrics.pending_block_requests = self .flashblocks_rpc_metrics @@ -14525,6 +15065,14 @@ where fn validate_flashblocks_setup(&self) -> Result<(), SubscriberError> { if self.config.preconfirmations == PreconfirmationMode::Disabled { + if self.uses_external_flashblock_updates() { + return Err(SubscriberError::InvalidConfig( + "external Flashblock updates require preconfirmations to be preferred or required", + )); + } + return Ok(()); + } + if self.uses_external_flashblock_updates() { return Ok(()); } if self.provider_ref.is_none() { @@ -15223,10 +15771,19 @@ where } fn stream_sources(&mut self) -> Result, SubscriberError> { - match resolve_subscriber_transport(self.mode)? { - SubscriberTransport::PubSub => Ok(self.pubsub_stream_sources()), - SubscriberTransport::Polling => Ok(self.polling_stream_sources()), - } + let sources = match resolve_subscriber_transport(self.mode)? { + SubscriberTransport::PubSub => self.pubsub_stream_sources(), + SubscriberTransport::Polling => self.polling_stream_sources(), + }; + #[cfg(feature = "raw-flashblocks-json")] + let sources = { + let mut sources = sources; + if self.external_flashblock_update_channel_opened { + sources.push(SubscriberStreamSource::ExternalFlashblockUpdates); + } + sources + }; + Ok(sources) } fn pubsub_stream_sources(&mut self) -> Vec { @@ -15246,7 +15803,8 @@ where } if needs_header_block_stream(&self.interests) { - if self.config.preconfirmations != PreconfirmationMode::Disabled + if !self.uses_external_flashblock_updates() + && self.config.preconfirmations != PreconfirmationMode::Disabled && self.chain_id.and_then(flashblocks_adapter).is_some() { sources.push(SubscriberStreamSource::CanonicalHeadPolling); @@ -15255,7 +15813,9 @@ where } } - if self.config.preconfirmations != PreconfirmationMode::Disabled { + if self.config.preconfirmations != PreconfirmationMode::Disabled + && !self.uses_external_flashblock_updates() + { match self.chain_id.and_then(flashblocks_adapter) { Some(FlashblocksAdapter::NativeSubscriptions) => { sources.push(SubscriberStreamSource::BaseFlashblocks); @@ -15286,6 +15846,7 @@ where } if self.config.preconfirmations != PreconfirmationMode::Disabled + && !self.uses_external_flashblock_updates() && self.chain_id.and_then(flashblocks_adapter) == Some(FlashblocksAdapter::PendingStatePolling) { @@ -15336,6 +15897,24 @@ where SubscriberStreamSource::PollingPendingHashes => { self.connect_polling_pending_hash_stream().await } + #[cfg(feature = "raw-flashblocks-json")] + SubscriberStreamSource::ExternalFlashblockUpdates => { + let receiver = self.external_flashblock_updates.take().ok_or_else(|| { + SubscriberError::Provider( + "external Flashblock update channel receiver is unavailable".into(), + ) + })?; + let updates = stream::unfold(receiver, |mut receiver| async move { + receiver + .recv() + .await + .map(|update| (SubscriberEvent::ExternalFlashblockUpdate(update), receiver)) + }); + Ok(stream_with_termination( + updates, + SubscriberStreamSource::ExternalFlashblockUpdates, + )) + } } } @@ -15644,6 +16223,25 @@ where match event { SubscriberEvent::StreamTerminated(source) => { + if source.is_external_flashblocks() { + #[cfg(feature = "raw-flashblocks-json")] + { + self.external_flashblock_update_channel_opened = false; + } + if let AlloySubscriberState::Active(streams) = &mut self.state { + streams + .entries + .retain(|entry| !entry.source.is_external_flashblocks()); + streams.normalize_next_index(); + } + self.invalidate_preconfirmation_snapshot(); + if self.config.preconfirmations == PreconfirmationMode::Required { + return Err(SubscriberError::Provider( + "required external Flashblock update channel closed".into(), + )); + } + return Ok(Some(SubscriberEvent::FlashblockInvalidated)); + } // Persist the missing-source intent before the first await. // If a control command cancels this poll during reconnect, // the next poll will reconcile the desired/live diff. @@ -15713,6 +16311,54 @@ where event: SubscriberEvent, ) -> Result>, SubscriberError> { match event { + #[cfg(feature = "raw-flashblocks-json")] + SubscriberEvent::ExternalFlashblockUpdate(queued) => { + let provider = queued.update.provider().clone(); + match self.ingest_flashblock_update(queued.update) { + Ok(()) => { + let _ = queued.acknowledgement.send(Ok(())); + Ok(Some(SubscriberEvent::FlashblockObserved)) + } + Err(error) + if self.config.preconfirmations == PreconfirmationMode::Preferred => + { + let recoverable_capacity = + matches!(error, SubscriberError::ResourceExhausted(_)); + if !recoverable_capacity + && let Some(configured) = self.external_flashblocks_provider.as_mut() + && configured.endpoint == provider.endpoint + { + self.rejected_external_flashblock_generation = Some( + self.rejected_external_flashblock_generation + .map_or(provider.generation, |rejected| { + rejected.max(provider.generation) + }), + ); + configured.generation = configured + .generation + .max(provider.generation.saturating_add(1)); + } + self.invalidate_preconfirmation_snapshot(); + self.last_external_flashblock_snapshot = None; + let _ = queued + .acknowledgement + .send(Err(FlashblockUpdateChannelError::Rejected)); + tracing::warn!( + provider = %provider.endpoint, + generation = provider.generation, + error = %error, + "external Flashblock update rejected; canonical delivery remains active" + ); + Ok(Some(SubscriberEvent::FlashblockInvalidated)) + } + Err(error) => { + let _ = queued + .acknowledgement + .send(Err(FlashblockUpdateChannelError::Rejected)); + Err(error) + } + } + } SubscriberEvent::BasePendingLog { source_id, log } => { let block_number = log.block_number.ok_or_else(|| { SubscriberError::Provider( @@ -15828,7 +16474,23 @@ where async fn fetch_certified_canonical_head( &mut self, ) -> Result>, SubscriberError> { - if self.chain_id.and_then(flashblocks_adapter) + tokio::time::timeout( + self.config.canonical_head_request_timeout, + self.fetch_certified_canonical_head_inner(), + ) + .await + .map_err(|_| { + SubscriberError::Provider(format!( + "canonical head certification timed out after {:?}", + self.config.canonical_head_request_timeout + )) + })? + } + + async fn fetch_certified_canonical_head_inner( + &mut self, + ) -> Result>, SubscriberError> { + if self.chain_id.and_then(flashblocks_adapter) == Some(FlashblocksAdapter::PendingStatePolling) { if !self.reserve_flashblock_rpc_methods(2) { @@ -16626,9 +17288,11 @@ where } let reported_hash = log.block_hash.and_then(non_placeholder_hash); if !samples_pending_range - && let (Some(reported), Some(expected)) = - (reported_hash, flashblock.partial_block_hash) - && reported != expected + && let Some(reported) = reported_hash + && reported != flashblock.content_hash + && flashblock + .partial_block_hash + .is_some_and(|expected| reported != expected) { return Err(SubscriberError::Provider( "pre-confirmed log partial block hash disagrees with its Flashblock snapshot" @@ -16664,6 +17328,8 @@ where } Ok(()) } + #[cfg(feature = "raw-flashblocks-json")] + SubscriberEvent::ExternalFlashblockUpdate(_) => Ok(()), SubscriberEvent::BlockHeader(_) | SubscriberEvent::PendingHash(_) | SubscriberEvent::PendingHashes(_) @@ -16760,6 +17426,8 @@ where self.buffer_reconcile_log_for_owners(log, InputSource::Poll, target_epochs); } } + #[cfg(feature = "raw-flashblocks-json")] + SubscriberEvent::ExternalFlashblockUpdate(_) => {} SubscriberEvent::BlockHeader(_) | SubscriberEvent::PendingHash(_) | SubscriberEvent::PendingHashes(_) @@ -16912,6 +17580,8 @@ where | SubscriberEvent::OpFlashblockTick | SubscriberEvent::CanonicalHeadTick | SubscriberEvent::FlashblockObserved => {} + #[cfg(feature = "raw-flashblocks-json")] + SubscriberEvent::ExternalFlashblockUpdate(_) => {} SubscriberEvent::StreamTerminated(_) => {} } } @@ -17619,6 +18289,10 @@ where SubscriberStreamSource::BasePendingLog { .. } | SubscriberStreamSource::BaseFlashblocks | SubscriberStreamSource::OpPendingFlashblocks => unreachable!(), + #[cfg(feature = "raw-flashblocks-json")] + SubscriberStreamSource::ExternalFlashblockUpdates => { + "Flashblocks reconnect cannot own an application-managed source" + } })), } } @@ -17670,8 +18344,29 @@ fn should_dedupe_record(record: &ReactiveInputRecord) -> bool { #[cfg(test)] mod subscriber_helper_tests { use super::*; + use alloy_json_rpc::{RequestPacket, ResponsePacket}; use alloy_provider::ProviderBuilder; - use alloy_transport::mock::Asserter; + use alloy_rpc_client::RpcClient; + use alloy_transport::{TransportError, TransportFut, mock::Asserter}; + use std::task::{Context, Poll}; + use tower::Service; + + #[derive(Clone, Debug)] + struct NeverRespondingTransport; + + impl Service for NeverRespondingTransport { + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, _context: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, _request: RequestPacket) -> Self::Future { + Box::pin(futures::future::pending()) + } + } fn indexed_flashblock(transaction_hash: B256, state_root: B256) -> BaseFlashblockWirePayload { BaseFlashblockWirePayload::Indexed(BaseFlashblockPayload { @@ -18011,6 +18706,576 @@ mod subscriber_helper_tests { ); } + #[test] + #[cfg(feature = "raw-flashblocks-json")] + fn external_flashblocks_keep_normal_canonical_pubsub_sources_on_any_chain() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4)) + .expect("configure external source"); + subscriber.chain_id = Some(1); + subscriber.base_interests = vec![ + ReactiveInterest::Blocks(BlockInterest::default()), + log_interest_matching_rpc_log(), + ]; + subscriber.interests = subscriber.base_interests.clone(); + + let pubsub = subscriber.pubsub_stream_sources(); + assert!( + pubsub + .iter() + .any(|source| matches!(source, SubscriberStreamSource::PubSubBlockHeaders)) + ); + assert!( + pubsub + .iter() + .any(|source| matches!(source, SubscriberStreamSource::PubSubLog { .. })) + ); + assert!(pubsub.iter().all(|source| !matches!( + source, + SubscriberStreamSource::BaseFlashblocks + | SubscriberStreamSource::BasePendingLog { .. } + | SubscriberStreamSource::OpPendingFlashblocks + | SubscriberStreamSource::CanonicalHeadPolling + ))); + assert!( + subscriber + .polling_stream_sources() + .iter() + .all(|source| { !matches!(source, SubscriberStreamSource::OpPendingFlashblocks) }) + ); + assert!( + subscriber + .capabilities() + .supports(SubscriberCapability::Preconfirmations) + ); + } + + #[test] + #[cfg(feature = "raw-flashblocks-json")] + fn external_flashblocks_are_rejected_when_preconfirmations_are_disabled() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ); + subscriber + .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4)) + .expect("configure external source"); + subscriber.chain_id = Some(1); + + assert!(matches!( + subscriber.validate_flashblocks_setup(), + Err(SubscriberError::InvalidConfig(message)) + if message.contains("require preconfirmations") + )); + } + + #[tokio::test] + #[cfg(feature = "raw-flashblocks-json")] + async fn external_flashblocks_configuration_is_rejected_after_registration_starts() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut fresh = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + fresh + .configure_external_flashblock_updates(ProviderRef::new("raw-json", 4)) + .expect("construction-time external source"); + + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut started = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ) + .with_provider_ref(ProviderRef::new("canonical", 3)); + started.chain_id = Some(8_453); + started + .register_interests(&[log_interest_matching_rpc_log()]) + .await + .expect("register canonical topology"); + + assert!(matches!( + started.configure_external_flashblock_updates(ProviderRef::new("raw-json", 4)), + Err(SubscriberError::InvalidConfig(message)) + if message.contains("before subscriber registration") + )); + } + + #[tokio::test] + #[cfg(feature = "raw-flashblocks-json")] + async fn external_flashblocks_preflight_performs_no_flashblocks_rpc() { + let asserter = Asserter::new(); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let source = ProviderRef::new("raw-json", 4); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + subscriber.chain_id = Some(1); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + let desired = subscriber.pubsub_stream_sources(); + let mut streams = SubscriberStreams::new(); + for source in desired { + streams.push(source, stream::pending().boxed()); + } + subscriber.state = AlloySubscriberState::Active(streams); + subscriber.sources_dirty = false; + + let preflight = subscriber + .establish_flashblocks_preflight(1) + .await + .expect("external source preflight"); + assert_eq!(preflight.provider(), &source); + assert_eq!(preflight.delivery(), FlashblocksDelivery::ExternalUpdates); + assert_eq!(preflight.pending_log_subscriptions(), 0); + assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0); + assert!(asserter.read_q().is_empty()); + } + + #[tokio::test] + #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))] + async fn bounded_external_channel_survives_subscriber_move_and_closure_keeps_canonical_stream() + { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let source = ProviderRef::new("raw-json", 4); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + subscriber.chain_id = Some(1); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + let filter = subscriber.log_stream_filters().remove(0); + let source_id = subscriber.log_source_id(&filter); + let mut streams = SubscriberStreams::new(); + streams.push( + SubscriberStreamSource::PubSubLog { + id: source_id, + filter, + }, + stream::pending().boxed(), + ); + subscriber.state = AlloySubscriberState::Active(streams); + subscriber.sources_dirty = false; + + let sender = subscriber + .open_external_flashblock_update_channel(2) + .expect("bounded external queue"); + let external = SubscriberStreamSource::ExternalFlashblockUpdates; + let update_stream = subscriber + .connect_source_stream(external.clone()) + .await + .expect("attach receiver as subscriber source"); + subscriber.install_source_stream(external, update_stream); + subscriber.sources_dirty = false; + + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let frame = br#"{ + "payload_id":"0x1111111111111111", + "index":0, + "base":{ + "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606", + "block_number":"0x7", + "timestamp":"0x6553f107" + }, + "diff":{ + "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "transactions":["0x01"] + }, + "metadata":{ + "block_number":7, + "receipts":{ + "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{ + "logs":[{ + "address":"0x4242424242424242424242424242424242424242", + "topics":["0x0101010101010101010101010101010101010101010101010101010101010101"], + "data":"0x" + }] + } + } + } + }"#; + let update = adapter + .ingest_json(frame) + .expect("valid raw update") + .expect("snapshot update"); + let valid_update = update.clone(); + let sending = { + let sender = sender.clone(); + tokio::spawn(async move { sender.send(update).await }) + }; + + let preview = subscriber + .next_scoped_batch() + .await + .expect("poll preview") + .expect("preview batch"); + assert_eq!(preview.records().len(), 1); + assert!(preview.records()[0].scope().is_preconfirmed()); + assert_eq!( + preview.records()[0].context.source, + InputSource::Flashblocks + ); + assert!(subscriber.latest_preconfirmation.is_some()); + assert_eq!(sending.await.expect("sender task"), Ok(())); + + let mut invalid_update = valid_update.clone(); + let FlashblockUpdate::Snapshot(snapshot) = &mut invalid_update else { + unreachable!("fixture is a snapshot") + }; + snapshot.logs[0].block_hash = Some(B256::repeat_byte(0xee)); + let rejecting = { + let sender = sender.clone(); + tokio::spawn(async move { sender.send(invalid_update).await }) + }; + let rejected = subscriber + .next_scoped_batch() + .await + .expect("preferred mode keeps polling") + .expect("rejected update invalidation"); + assert!(rejected.preconfirmation_invalidated()); + assert!(rejected.records().is_empty()); + assert!(subscriber.latest_preconfirmation.is_none()); + assert_eq!( + rejecting.await.expect("sender task"), + Err(FlashblockUpdateChannelError::Rejected) + ); + subscriber + .ingest_flashblock_update(valid_update) + .expect("rejected generation is ignored thereafter"); + assert!(subscriber.latest_preconfirmation.is_none()); + + let _reset = adapter + .reset(ProviderRef::new("raw-json", 5)) + .expect("advance rejected source generation"); + let recovered_update = adapter + .ingest_json(frame) + .expect("valid replacement generation") + .expect("replacement snapshot update"); + let recovering = { + let sender = sender.clone(); + tokio::spawn(async move { sender.send(recovered_update).await }) + }; + let recovered = subscriber + .next_scoped_batch() + .await + .expect("poll replacement generation") + .expect("replacement preview batch"); + assert_eq!(recovered.records().len(), 1); + assert!(matches!( + &recovered.records()[0].context.chain_status, + ChainStatus::Preconfirmed { flashblock } + if flashblock.provider == ProviderRef::new("raw-json", 5) + )); + assert_eq!(recovering.await.expect("sender task"), Ok(())); + + drop(sender); + let invalidation = subscriber + .next_scoped_batch() + .await + .expect("poll channel closure") + .expect("closure invalidation"); + assert!(invalidation.preconfirmation_invalidated()); + assert!(invalidation.records().is_empty()); + assert!(subscriber.latest_preconfirmation.is_none()); + assert!(matches!( + &subscriber.state, + AlloySubscriberState::Active(streams) + if streams.entries.iter().any(|entry| matches!( + entry.source, + SubscriberStreamSource::PubSubLog { id, .. } if id == source_id + )) + )); + } + + #[tokio::test] + #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))] + async fn required_external_channel_closure_fails_the_subscriber_closed() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let source = ProviderRef::new("raw-json", 4); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source) + .expect("configure external source"); + subscriber.chain_id = Some(1); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new()); + subscriber.sources_dirty = false; + + let sender = subscriber + .open_external_flashblock_update_channel(1) + .expect("bounded external queue"); + let external = SubscriberStreamSource::ExternalFlashblockUpdates; + let update_stream = subscriber + .connect_source_stream(external.clone()) + .await + .expect("attach receiver as subscriber source"); + subscriber.install_source_stream(external, update_stream); + subscriber.sources_dirty = false; + drop(sender); + + assert!(matches!( + subscriber.next_scoped_batch().await, + Err(SubscriberError::Provider(ref message)) + if message.contains("required external Flashblock update channel closed") + )); + } + + #[tokio::test] + #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))] + async fn required_external_channel_rejects_a_queued_malformed_update() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let source = ProviderRef::new("raw-json", 4); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + subscriber.chain_id = Some(1); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new()); + subscriber.sources_dirty = false; + + let sender = subscriber + .open_external_flashblock_update_channel(1) + .expect("bounded external queue"); + let external = SubscriberStreamSource::ExternalFlashblockUpdates; + let update_stream = subscriber + .connect_source_stream(external.clone()) + .await + .expect("attach receiver as subscriber source"); + subscriber.install_source_stream(external, update_stream); + subscriber.sources_dirty = false; + + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let mut update = adapter + .ingest_json( + br#"{ + "payload_id":"0x1111111111111111", + "index":0, + "base":{ + "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606", + "block_number":"0x7", + "timestamp":"0x6553f107" + }, + "diff":{ + "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "transactions":[] + }, + "metadata":{"block_number":7,"receipts":{}} + }"#, + ) + .expect("valid raw frame") + .expect("snapshot update"); + let FlashblockUpdate::Snapshot(snapshot) = &mut update else { + unreachable!("fixture is a snapshot") + }; + snapshot.flashblock.content_hash = B256::ZERO; + let sending = tokio::spawn(async move { sender.send(update).await }); + + assert!(matches!( + subscriber.next_scoped_batch().await, + Err(SubscriberError::Provider(ref message)) + if message.contains("content commitment is invalid") + )); + assert_eq!( + sending.await.expect("sender task"), + Err(FlashblockUpdateChannelError::Rejected) + ); + } + + #[tokio::test] + #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))] + async fn bounded_external_channel_reports_capacity_rejection_and_accepts_a_new_generation() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let source = ProviderRef::new("raw-json", 4); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + max_pending_records: 1, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + subscriber.chain_id = Some(1); + subscriber.base_interests = vec![log_interest_matching_rpc_log()]; + subscriber.interests = subscriber.base_interests.clone(); + subscriber.state = AlloySubscriberState::Active(SubscriberStreams::new()); + subscriber.sources_dirty = false; + + let sender = subscriber + .open_external_flashblock_update_channel(1) + .expect("bounded external queue"); + let external = SubscriberStreamSource::ExternalFlashblockUpdates; + let update_stream = subscriber + .connect_source_stream(external.clone()) + .await + .expect("attach receiver as subscriber source"); + subscriber.install_source_stream(external, update_stream); + subscriber.sources_dirty = false; + + let first_frame = br#"{ + "payload_id":"0x1111111111111111", + "index":0, + "base":{ + "parent_hash":"0x0606060606060606060606060606060606060606060606060606060606060606", + "block_number":"0x7", + "timestamp":"0x6553f107" + }, + "diff":{ + "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "transactions":["0x01"] + }, + "metadata":{ + "block_number":7, + "receipts":{ + "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{ + "logs":[{ + "address":"0x4242424242424242424242424242424242424242", + "topics":["0x0101010101010101010101010101010101010101010101010101010101010101"], + "data":"0x" + }] + } + } + } + }"#; + let second_frame = br#"{ + "payload_id":"0x1111111111111111", + "index":1, + "diff":{ + "state_root":"0xabababababababababababababababababababababababababababababababab", + "block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "transactions":["0x02"] + }, + "metadata":{ + "block_number":7, + "receipts":{ + "0xf2ee15ea639b73fa3db9b34a245bdfa015c260c598b211bf05a1ecc4b3e3b4f2":{ + "logs":[ + {"address":"0x4444444444444444444444444444444444444444","topics":[],"data":"0x"}, + {"address":"0x4545454545454545454545454545454545454545","topics":[],"data":"0x"} + ] + } + } + } + }"#; + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let first = adapter + .ingest_json(first_frame) + .expect("valid first frame") + .expect("first snapshot"); + let first_send = { + let sender = sender.clone(); + tokio::spawn(async move { sender.send(first).await }) + }; + let first_batch = subscriber + .next_scoped_batch() + .await + .expect("poll first preview") + .expect("first preview batch"); + assert_eq!(first_batch.records().len(), 1); + assert_eq!(first_send.await.expect("sender task"), Ok(())); + + let oversized = adapter + .ingest_json(second_frame) + .expect("valid oversized delta") + .expect("oversized standardized snapshot"); + let rejected_send = { + let sender = sender.clone(); + tokio::spawn(async move { sender.send(oversized).await }) + }; + let invalidation = subscriber + .next_scoped_batch() + .await + .expect("poll capacity rejection") + .expect("capacity invalidation batch"); + assert!(invalidation.preconfirmation_invalidated()); + assert_eq!( + rejected_send.await.expect("sender task"), + Err(FlashblockUpdateChannelError::Rejected) + ); + assert_eq!(subscriber.rejected_external_flashblock_generation, None); + + let _ = adapter + .reset(ProviderRef::new("raw-json", 5)) + .expect("advance after local capacity rejection"); + let recovered = adapter + .ingest_json(first_frame) + .expect("valid recovered frame") + .expect("recovered snapshot"); + let recovered_send = { + let sender = sender.clone(); + tokio::spawn(async move { sender.send(recovered).await }) + }; + let recovered_batch = subscriber + .next_scoped_batch() + .await + .expect("poll recovered generation") + .expect("recovered preview batch"); + assert_eq!(recovered_batch.records().len(), 1); + assert!(matches!( + &recovered_batch.records()[0].context.chain_status, + ChainStatus::Preconfirmed { flashblock } + if flashblock.provider == ProviderRef::new("raw-json", 5) + )); + assert_eq!(recovered_send.await.expect("sender task"), Ok(())); + } + #[tokio::test] async fn certified_canonical_heads_are_deduplicated_and_reject_placeholder_hashes() { let asserter = Asserter::new(); @@ -18046,6 +19311,34 @@ mod subscriber_helper_tests { )); } + #[tokio::test] + async fn canonical_head_certification_times_out_a_silent_provider() { + let provider = + ProviderBuilder::new().connect_client(RpcClient::new(NeverRespondingTransport, true)); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + canonical_head_request_timeout: Duration::from_millis(10), + ..SubscriberConfig::default() + }, + ); + subscriber.chain_id = Some(8_453); + + let result = tokio::time::timeout( + Duration::from_millis(100), + subscriber.fetch_certified_canonical_head(), + ) + .await + .expect("subscriber must bound a silent provider request"); + assert!(matches!( + result, + Err(SubscriberError::Provider(ref message)) + if message.contains("canonical head certification timed out") + )); + } + #[tokio::test] async fn optimism_canonical_head_is_the_exact_parent_of_pending() { let asserter = Asserter::new(); @@ -18184,6 +19477,22 @@ mod subscriber_helper_tests { )); } + #[test] + fn flashblocks_config_rejects_a_zero_canonical_head_request_timeout() { + let config = SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + canonical_head_request_timeout: Duration::ZERO, + ..SubscriberConfig::default() + }; + + assert!(matches!( + validate_subscriber_config(&config), + Err(SubscriberError::InvalidConfig( + "SubscriberConfig::canonical_head_request_timeout must be greater than zero" + )) + )); + } + #[tokio::test] async fn optimism_preflight_rejects_a_budget_without_receipt_capacity() { let asserter = Asserter::new(); @@ -21977,6 +23286,13 @@ fn validate_subscriber_config(config: &SubscriberConfig) -> Result<(), Subscribe "SubscriberConfig::canonical_head_poll_interval must be greater than zero", )); } + if config.preconfirmations != PreconfirmationMode::Disabled + && config.canonical_head_request_timeout.is_zero() + { + return Err(SubscriberError::InvalidConfig( + "SubscriberConfig::canonical_head_request_timeout must be greater than zero", + )); + } if config.preconfirmations != PreconfirmationMode::Disabled && config.flashblock_poll_interval.is_zero() { diff --git a/src/reactive/raw_json_flashblocks.rs b/src/reactive/raw_json_flashblocks.rs new file mode 100644 index 0000000..aeb74ea --- /dev/null +++ b/src/reactive/raw_json_flashblocks.rs @@ -0,0 +1,837 @@ +use std::collections::{HashMap, HashSet}; + +use alloy_primitives::{Address, B256, Bytes, FixedBytes, Keccak256, Log as PrimitiveLog}; +use alloy_rpc_types_eth::Log; +use tokio::sync::{mpsc, oneshot}; + +use super::{ + BaseFlashblockBase, FlashblockContentCommitment, FlashblockRef, ProviderRef, + deserialize_optional_rpc_u64, flashblock_content_hash, flashblock_transaction_hashes, + non_placeholder_hash, +}; + +/// Resource bounds applied while converting receipt-enriched JSON Flashblocks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct RawJsonFlashblocksLimits { + /// Largest accepted JSON frame. + pub max_frame_bytes: usize, + /// Largest accepted index for one payload generation. + pub max_flashblocks_per_payload: usize, + /// Largest cumulative transaction membership retained for one generation. + pub max_transactions_per_payload: usize, + /// Largest cumulative receipt-log count retained for one generation. + pub max_logs_per_payload: usize, +} + +impl Default for RawJsonFlashblocksLimits { + fn default() -> Self { + Self { + max_frame_bytes: 16 * 1024 * 1024, + max_flashblocks_per_payload: 64, + max_transactions_per_payload: 50_000, + max_logs_per_payload: 200_000, + } + } +} + +impl RawJsonFlashblocksLimits { + fn validate(self) -> Result<(), RawJsonFlashblocksError> { + if self.max_frame_bytes == 0 { + return Err(RawJsonFlashblocksError::InvalidLimits( + "max_frame_bytes must be greater than zero", + )); + } + if self.max_flashblocks_per_payload == 0 { + return Err(RawJsonFlashblocksError::InvalidLimits( + "max_flashblocks_per_payload must be greater than zero", + )); + } + if self.max_transactions_per_payload == 0 { + return Err(RawJsonFlashblocksError::InvalidLimits( + "max_transactions_per_payload must be greater than zero", + )); + } + if self.max_logs_per_payload == 0 { + return Err(RawJsonFlashblocksError::InvalidLimits( + "max_logs_per_payload must be greater than zero", + )); + } + Ok(()) + } +} + +/// One provider-provenanced cumulative preview plus the logs added by its +/// latest indexed delta. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct FlashblockSnapshot { + /// Identity and cumulative transaction membership for the preview. + pub flashblock: FlashblockRef, + /// Structured logs added by this exact indexed delta. + pub logs: Vec, +} + +/// Why a speculative Flashblock payload generation was revoked. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum FlashblockInvalidationReason { + /// The source skipped at least one index within a payload generation. + IndexGap, + /// A repeated index carried different content. + ConflictingDuplicate, + /// A generation began after index zero, so its base header was unavailable. + MissingInitialIndex, + /// The caller replaced or disconnected the externally managed source. + SourceReset, +} + +/// Observable fail-closed invalidation for one provider generation. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct FlashblockInvalidation { + /// Provider generation whose speculative payload was revoked. + pub provider: ProviderRef, + /// Payload identity that was active or could not be trusted. + pub payload_id: FixedBytes<8>, + /// Continuity or caller lifecycle transition that caused the revocation. + pub reason: FlashblockInvalidationReason, +} + +/// Standardized update accepted by the existing preconfirmation pipeline. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum FlashblockUpdate { + /// A cumulative preview and its newly added structured logs. + Snapshot(Box), + /// The active speculative provider generation must be discarded. + Invalidated(FlashblockInvalidation), +} + +impl FlashblockUpdate { + /// Provider generation carried by this standardized update. + pub const fn provider(&self) -> &ProviderRef { + match self { + Self::Snapshot(snapshot) => &snapshot.flashblock.provider, + Self::Invalidated(invalidation) => &invalidation.provider, + } + } +} + +/// Failure to enqueue a standardized update into an attached subscriber. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum FlashblockUpdateChannelError { + /// The update names a different configured endpoint. + #[error("Flashblock update came from an unexpected provider endpoint")] + UnexpectedEndpoint, + /// A non-blocking send found the bounded channel at capacity. + #[error("Flashblock update channel is full")] + Full, + /// The subscriber no longer owns the receiving half of the channel. + #[error("Flashblock update channel is closed")] + Closed, + /// The subscriber consumed the update but rejected its integrity or local + /// resource requirements. The application must revoke the source + /// generation before continuing. + #[error("Flashblock update was rejected by the subscriber")] + Rejected, +} + +/// Subscriber verdict for one non-blocking standardized update admission. +/// +/// Awaiting this receipt distinguishes bounded-queue admission from actual +/// subscriber validation. Dropping it does not cancel the queued update. +#[derive(Debug)] +#[must_use = "await wait() to observe the subscriber validation verdict"] +pub struct FlashblockUpdateAcknowledgement { + receiver: oneshot::Receiver>, +} + +impl FlashblockUpdateAcknowledgement { + /// Wait until the subscriber accepts or rejects the queued update. + /// + /// # Errors + /// + /// Returns [`FlashblockUpdateChannelError::Rejected`] after subscriber + /// validation failure, or [`FlashblockUpdateChannelError::Closed`] when the + /// subscriber shuts down before producing a verdict. + pub async fn wait(self) -> Result<(), FlashblockUpdateChannelError> { + self.receiver + .await + .unwrap_or(Err(FlashblockUpdateChannelError::Closed)) + } +} + +pub(crate) struct QueuedFlashblockUpdate { + pub(crate) update: FlashblockUpdate, + pub(crate) acknowledgement: oneshot::Sender>, +} + +impl QueuedFlashblockUpdate { + fn new(update: FlashblockUpdate) -> (Self, FlashblockUpdateAcknowledgement) { + let (acknowledgement, receiver) = oneshot::channel(); + ( + Self { + update, + acknowledgement, + }, + FlashblockUpdateAcknowledgement { receiver }, + ) + } +} + +/// Cloneable application handle for a subscriber-owned, bounded update queue. +/// +/// This handle performs no network I/O and implements no retry policy. The +/// application keeps it while the [`super::AlloySubscriber`] may be moved into +/// another runtime owner, and sends only updates produced by a compatible +/// adapter. Backpressure, reconnects, and source rotation remain application +/// responsibilities. +#[derive(Clone, Debug)] +pub struct FlashblockUpdateSender { + provider: ProviderRef, + sender: mpsc::Sender, +} + +impl FlashblockUpdateSender { + pub(crate) const fn new( + provider: ProviderRef, + sender: mpsc::Sender, + ) -> Self { + Self { provider, sender } + } + + /// Configured endpoint and initial generation for this queue. + pub const fn provider(&self) -> &ProviderRef { + &self.provider + } + + /// Await bounded queue capacity, enqueue one standardized update, and wait + /// for the subscriber's validation verdict. + /// + /// # Errors + /// + /// Returns [`FlashblockUpdateChannelError::UnexpectedEndpoint`] before + /// enqueueing an update from another endpoint, or + /// [`FlashblockUpdateChannelError::Closed`] after subscriber shutdown. + /// [`FlashblockUpdateChannelError::Rejected`] means the subscriber consumed + /// the update but rejected its integrity or local resource requirements; + /// revoke and replace that source generation before continuing. + pub async fn send(&self, update: FlashblockUpdate) -> Result<(), FlashblockUpdateChannelError> { + self.validate_endpoint(&update)?; + let (queued, acknowledgement) = QueuedFlashblockUpdate::new(update); + self.sender + .send(queued) + .await + .map_err(|_| FlashblockUpdateChannelError::Closed)?; + acknowledgement.wait().await + } + + /// Enqueue one standardized update without waiting for capacity and return + /// a receipt for the subscriber's eventual validation verdict. + /// + /// # Errors + /// + /// In addition to endpoint and closure errors, returns + /// [`FlashblockUpdateChannelError::Full`] when the bounded queue has no + /// immediate capacity. A successful return proves only queue admission; + /// await [`FlashblockUpdateAcknowledgement::wait`] before treating the + /// update as accepted. The caller decides whether to wait, invalidate its + /// current generation, or reconnect the external source. + pub fn try_send( + &self, + update: FlashblockUpdate, + ) -> Result { + self.validate_endpoint(&update)?; + let (queued, acknowledgement) = QueuedFlashblockUpdate::new(update); + self.sender.try_send(queued).map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => FlashblockUpdateChannelError::Full, + mpsc::error::TrySendError::Closed(_) => FlashblockUpdateChannelError::Closed, + })?; + Ok(acknowledgement) + } + + fn validate_endpoint( + &self, + update: &FlashblockUpdate, + ) -> Result<(), FlashblockUpdateChannelError> { + if update.provider().endpoint != self.provider.endpoint { + return Err(FlashblockUpdateChannelError::UnexpectedEndpoint); + } + Ok(()) + } +} + +pub(crate) fn flashblock_update_channel( + provider: ProviderRef, + capacity: usize, +) -> ( + FlashblockUpdateSender, + mpsc::Receiver, +) { + let (sender, receiver) = mpsc::channel(capacity); + (FlashblockUpdateSender::new(provider, sender), receiver) +} + +/// A malformed, unsupported, or resource-exhausting raw JSON Flashblocks frame. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum RawJsonFlashblocksError { + /// Adapter bounds are unusable. + #[error("invalid raw JSON Flashblocks limits: {0}")] + InvalidLimits(&'static str), + /// A caller attempted an ambiguous or regressing source transition. + #[error("invalid raw JSON Flashblocks source transition: {0}")] + InvalidSourceTransition(&'static str), + /// The frame exceeded the configured byte bound. + #[error("raw JSON Flashblocks frame exceeds the configured byte limit")] + FrameTooLarge, + /// The JSON document did not match the supported receipt-enriched profile. + #[error("invalid raw JSON Flashblocks payload: {0}")] + InvalidPayload(String), + /// A configured per-payload resource bound was exceeded. + #[error("raw JSON Flashblocks payload exceeds the configured {0} limit")] + ResourceExhausted(&'static str), +} + +/// Stateful converter for receipt-enriched, indexed JSON Flashblock payloads. +/// +/// Compatibility is defined by the accepted wire profile, not by chain id: +/// `payload_id`, a monotonically increasing `index`, an index-zero `base` (or +/// `static`) header, transaction deltas in `diff.transactions`, and an exact +/// receipt map in `metadata.receipts`. Provider JSON-RPC subscription envelopes, +/// receipt-less payloads, and binary SSZ frames are separate wire profiles and +/// are not accepted by this adapter. +/// +/// The adapter performs no I/O. The caller owns WebSocket control frames, +/// authentication, timeouts, retry, backoff, and provider rotation. On source +/// replacement or disconnect, call [`Self::reset`] and forward the returned +/// invalidation before accepting updates from the new provider generation. +#[derive(Debug)] +pub struct RawJsonFlashblocksAdapter { + provider: ProviderRef, + limits: RawJsonFlashblocksLimits, + active: Option, + ignored_payload: Option>, +} + +impl RawJsonFlashblocksAdapter { + /// Construct an adapter with bounded production defaults. + pub fn new(provider: ProviderRef) -> Self { + Self { + provider, + limits: RawJsonFlashblocksLimits::default(), + active: None, + ignored_payload: None, + } + } + + /// Construct an adapter with explicit resource bounds. + pub fn with_limits( + provider: ProviderRef, + limits: RawJsonFlashblocksLimits, + ) -> Result { + limits.validate()?; + Ok(Self { + provider, + limits, + active: None, + ignored_payload: None, + }) + } + + /// Provider generation attached to newly normalized snapshots. + pub const fn provider(&self) -> &ProviderRef { + &self.provider + } + + /// Resource limits applied by this adapter. + pub const fn limits(&self) -> RawJsonFlashblocksLimits { + self.limits + } + + /// Revoke the active payload and begin a caller-managed provider generation. + /// + /// This method never reconnects, sleeps, or performs I/O. `None` means the + /// prior source had no active payload to revoke. + /// + /// # Errors + /// + /// Returns [`RawJsonFlashblocksError::InvalidSourceTransition`] when the + /// endpoint identity changes or the generation does not increase. Rebuild + /// the adapter and subscriber binding to select a different endpoint. + pub fn reset( + &mut self, + provider: ProviderRef, + ) -> Result, RawJsonFlashblocksError> { + if provider.endpoint != self.provider.endpoint { + return Err(RawJsonFlashblocksError::InvalidSourceTransition( + "reset cannot change the configured endpoint identity", + )); + } + if provider.generation <= self.provider.generation { + return Err(RawJsonFlashblocksError::InvalidSourceTransition( + "reset requires a strictly newer provider generation", + )); + } + let invalidation = self.active.take().map(|active| { + FlashblockUpdate::Invalidated(FlashblockInvalidation { + provider: self.provider.clone(), + payload_id: active.payload_id, + reason: FlashblockInvalidationReason::SourceReset, + }) + }); + self.provider = provider; + self.ignored_payload = None; + Ok(invalidation) + } + + /// Decode and normalize one raw JSON application-data frame. + /// + /// `Ok(None)` denotes an identical duplicate or a later delta from a + /// generation already invalidated for continuity loss. `Err` leaves the + /// last successfully published adapter state unchanged. A live caller that + /// cannot prove an application-data error irrelevant must call + /// [`Self::reset`] with a newer generation and forward the returned + /// invalidation before accepting more frames. + pub fn ingest_json( + &mut self, + frame: &[u8], + ) -> Result, RawJsonFlashblocksError> { + if frame.len() > self.limits.max_frame_bytes { + return Err(RawJsonFlashblocksError::FrameTooLarge); + } + let payload: RawFlashblockPayload = serde_json::from_slice(frame) + .map_err(|error| RawJsonFlashblocksError::InvalidPayload(error.to_string()))?; + self.ingest(payload) + } + + fn ingest( + &mut self, + payload: RawFlashblockPayload, + ) -> Result, RawJsonFlashblocksError> { + if usize::try_from(payload.index) + .ok() + .is_none_or(|index| index >= self.limits.max_flashblocks_per_payload) + { + return Err(RawJsonFlashblocksError::ResourceExhausted( + "Flashblock index", + )); + } + if self.ignored_payload == Some(payload.payload_id) { + return Ok(None); + } + + let begins_new_payload = self + .active + .as_ref() + .is_none_or(|active| active.payload_id != payload.payload_id); + if begins_new_payload { + if payload.index != 0 { + let invalidated_payload = self + .active + .take() + .map_or(payload.payload_id, |active| active.payload_id); + self.ignored_payload = Some(payload.payload_id); + return Ok(Some(self.invalidation( + invalidated_payload, + FlashblockInvalidationReason::MissingInitialIndex, + ))); + } + let base = payload.base.clone().ok_or_else(|| { + RawJsonFlashblocksError::InvalidPayload("index zero omitted its base header".into()) + })?; + if let Some(metadata_number) = payload.metadata.block_number + && metadata_number != base.block_number + { + return Err(RawJsonFlashblocksError::InvalidPayload( + "base and metadata block numbers disagree".into(), + )); + } + let previous_active = self.active.take(); + let previous_ignored_payload = self.ignored_payload.take(); + self.active = Some(RawPayloadState { + payload_id: payload.payload_id, + base, + last_index: None, + cumulative_transactions: Vec::new(), + transaction_set: HashSet::new(), + next_log_index: 0, + cumulative_logs: 0, + index_commitments: HashMap::new(), + }); + let result = self.ingest_active(payload); + if result.is_err() { + self.active = previous_active; + self.ignored_payload = previous_ignored_payload; + } + return result; + } + + self.ingest_active(payload) + } + + fn ingest_active( + &mut self, + payload: RawFlashblockPayload, + ) -> Result, RawJsonFlashblocksError> { + let active = self.active.as_mut().expect("new payload initialized above"); + if payload + .metadata + .block_number + .is_some_and(|number| number != active.base.block_number) + || payload + .base + .as_ref() + .is_some_and(|base| base != &active.base) + { + return Err(RawJsonFlashblocksError::InvalidPayload( + "base header or metadata block numbers disagree with the active payload".into(), + )); + } + let delta_transactions = flashblock_transaction_hashes(&payload.diff.transactions) + .map_err(|error| RawJsonFlashblocksError::InvalidPayload(error.to_string()))?; + let receipt_hashes = payload + .metadata + .receipts + .keys() + .copied() + .collect::>(); + let transaction_hashes = delta_transactions.iter().copied().collect::>(); + if transaction_hashes.len() != delta_transactions.len() { + return Err(RawJsonFlashblocksError::InvalidPayload( + "the transaction delta contains a duplicate hash".into(), + )); + } + if receipt_hashes != transaction_hashes { + return Err(RawJsonFlashblocksError::InvalidPayload( + "receipt-map membership disagrees with the transaction delta".into(), + )); + } + let commitment = raw_payload_commitment(&payload, &delta_transactions); + if let Some(previous) = active.index_commitments.get(&payload.index) { + if *previous == commitment { + return Ok(None); + } + let payload_id = active.payload_id; + self.active = None; + self.ignored_payload = Some(payload_id); + return Ok(Some(self.invalidation( + payload_id, + FlashblockInvalidationReason::ConflictingDuplicate, + ))); + } + let expected_index = active.last_index.map_or(0, |index| index.saturating_add(1)); + if payload.index != expected_index { + let payload_id = active.payload_id; + self.active = None; + self.ignored_payload = Some(payload_id); + return Ok(Some( + self.invalidation(payload_id, FlashblockInvalidationReason::IndexGap), + )); + } + + if active + .cumulative_transactions + .len() + .saturating_add(delta_transactions.len()) + > self.limits.max_transactions_per_payload + { + return Err(RawJsonFlashblocksError::ResourceExhausted( + "transaction count", + )); + } + if delta_transactions + .iter() + .any(|hash| active.transaction_set.contains(hash)) + { + return Err(RawJsonFlashblocksError::InvalidPayload( + "a transaction appeared in more than one indexed delta".into(), + )); + } + + let transaction_offset = active.cumulative_transactions.len(); + let mut logs = Vec::new(); + for (delta_index, transaction_hash) in delta_transactions.iter().enumerate() { + let receipt = payload + .metadata + .receipts + .get(transaction_hash) + .expect("receipt membership checked above"); + let transaction_index = + u64::try_from(transaction_offset.saturating_add(delta_index)) + .map_err(|_| RawJsonFlashblocksError::ResourceExhausted("transaction index"))?; + for raw_log in &receipt.logs { + if active.cumulative_logs.saturating_add(logs.len()) + >= self.limits.max_logs_per_payload + { + return Err(RawJsonFlashblocksError::ResourceExhausted("log count")); + } + let inner = PrimitiveLog::new( + raw_log.address, + raw_log.topics.clone(), + raw_log.data.clone(), + ) + .ok_or_else(|| { + RawJsonFlashblocksError::InvalidPayload( + "receipt log contains more than four topics".into(), + ) + })?; + let log_index = active + .next_log_index + .checked_add( + u64::try_from(logs.len()) + .map_err(|_| RawJsonFlashblocksError::ResourceExhausted("log index"))?, + ) + .ok_or(RawJsonFlashblocksError::ResourceExhausted("log index"))?; + logs.push(Log { + inner, + block_hash: None, + block_number: Some(active.base.block_number), + block_timestamp: Some(active.base.timestamp), + transaction_hash: Some(*transaction_hash), + transaction_index: Some(transaction_index), + log_index: Some(log_index), + removed: false, + }); + } + } + + let mut cumulative_transactions = active.cumulative_transactions.clone(); + cumulative_transactions.extend(delta_transactions.iter().copied()); + let partial_block_hash = non_placeholder_hash(payload.diff.block_hash); + let state_root = non_placeholder_hash(payload.diff.state_root); + let transactions_root = payload + .diff + .transactions_root + .and_then(non_placeholder_hash); + let parent_hash = non_placeholder_hash(active.base.parent_hash); + let prevrandao = active.base.prevrandao.and_then(non_placeholder_hash); + let content_hash = flashblock_content_hash(FlashblockContentCommitment { + provider: &self.provider, + payload_id: Some(payload.payload_id), + index: Some(payload.index), + block_number: active.base.block_number, + partial_block_hash, + parent_hash, + state_root, + transactions_root, + transaction_hashes: &cumulative_transactions, + timestamp: Some(active.base.timestamp), + base_fee_per_gas: active.base.base_fee_per_gas, + beneficiary: active.base.beneficiary, + prevrandao, + gas_limit: active.base.gas_limit, + }); + for log in &mut logs { + log.block_hash = Some(content_hash); + } + let flashblock = FlashblockRef { + provider: self.provider.clone(), + payload_id: Some(payload.payload_id), + index: Some(payload.index), + block_number: active.base.block_number, + content_hash, + partial_block_hash, + parent_hash, + state_root, + transactions_root, + transaction_hashes: cumulative_transactions.clone(), + timestamp: Some(active.base.timestamp), + base_fee_per_gas: active.base.base_fee_per_gas, + beneficiary: active.base.beneficiary, + prevrandao, + gas_limit: active.base.gas_limit, + }; + let next_log_index = active + .next_log_index + .checked_add( + u64::try_from(logs.len()) + .map_err(|_| RawJsonFlashblocksError::ResourceExhausted("log index"))?, + ) + .ok_or(RawJsonFlashblocksError::ResourceExhausted("log index"))?; + active.transaction_set.extend(delta_transactions); + active.cumulative_transactions = cumulative_transactions; + active.last_index = Some(payload.index); + active.index_commitments.insert(payload.index, commitment); + active.next_log_index = next_log_index; + active.cumulative_logs = active.cumulative_logs.saturating_add(logs.len()); + + Ok(Some(FlashblockUpdate::Snapshot(Box::new( + FlashblockSnapshot { flashblock, logs }, + )))) + } + + fn invalidation( + &self, + payload_id: FixedBytes<8>, + reason: FlashblockInvalidationReason, + ) -> FlashblockUpdate { + FlashblockUpdate::Invalidated(FlashblockInvalidation { + provider: self.provider.clone(), + payload_id, + reason, + }) + } +} + +fn raw_payload_commitment(payload: &RawFlashblockPayload, transaction_hashes: &[B256]) -> B256 { + let mut commitment = Keccak256::new(); + commitment.update(b"evm-fork-cache/raw-json-flashblock/v1"); + commitment.update(payload.payload_id.as_slice()); + commitment.update(payload.index.to_be_bytes()); + match payload.base.as_ref() { + Some(base) => { + commitment.update([1]); + commitment.update(base.parent_hash.as_slice()); + commitment.update(base.block_number.to_be_bytes()); + commitment.update(base.timestamp.to_be_bytes()); + commit_optional_raw_u64(&mut commitment, base.gas_limit); + commit_optional_raw_u64(&mut commitment, base.base_fee_per_gas); + commit_optional_raw_bytes( + &mut commitment, + base.beneficiary.as_ref().map(|address| address.as_slice()), + ); + commit_optional_raw_bytes( + &mut commitment, + base.prevrandao.as_ref().map(B256::as_slice), + ); + } + None => commitment.update([0]), + } + commitment.update(payload.diff.state_root.as_slice()); + commitment.update(payload.diff.block_hash.as_slice()); + commit_optional_raw_bytes( + &mut commitment, + payload.diff.transactions_root.as_ref().map(B256::as_slice), + ); + commit_optional_raw_u64(&mut commitment, payload.metadata.block_number); + commitment.update((transaction_hashes.len() as u64).to_be_bytes()); + for transaction_hash in transaction_hashes { + commitment.update(transaction_hash.as_slice()); + let receipt = payload + .metadata + .receipts + .get(transaction_hash) + .expect("receipt membership validated before commitment"); + commitment.update((receipt.logs.len() as u64).to_be_bytes()); + for log in &receipt.logs { + commitment.update(log.address.as_slice()); + commitment.update((log.topics.len() as u64).to_be_bytes()); + for topic in &log.topics { + commitment.update(topic.as_slice()); + } + commitment.update((log.data.len() as u64).to_be_bytes()); + commitment.update(log.data.as_ref()); + } + } + commitment.finalize() +} + +fn commit_optional_raw_u64(commitment: &mut Keccak256, value: Option) { + match value { + Some(value) => { + commitment.update([1]); + commitment.update(value.to_be_bytes()); + } + None => commitment.update([0]), + } +} + +fn commit_optional_raw_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) { + match value { + Some(value) => { + commitment.update([1]); + commitment.update((value.len() as u64).to_be_bytes()); + commitment.update(value); + } + None => commitment.update([0]), + } +} + +#[derive(Debug)] +struct RawPayloadState { + payload_id: FixedBytes<8>, + base: BaseFlashblockBase, + last_index: Option, + cumulative_transactions: Vec, + transaction_set: HashSet, + next_log_index: u64, + cumulative_logs: usize, + index_commitments: HashMap, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct RawFlashblockPayload { + payload_id: FixedBytes<8>, + index: u64, + #[serde(default, alias = "static")] + base: Option, + diff: RawFlashblockDiff, + metadata: RawFlashblockMetadata, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct RawFlashblockDiff { + state_root: B256, + block_hash: B256, + #[serde(default)] + transactions: Vec, + #[serde(default)] + transactions_root: Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct RawFlashblockMetadata { + #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")] + block_number: Option, + #[serde(default, deserialize_with = "deserialize_receipts")] + receipts: HashMap, +} + +fn deserialize_receipts<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct ReceiptsVisitor; + + impl<'de> serde::de::Visitor<'de> for ReceiptsVisitor { + type Value = HashMap; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a receipt map with unique transaction-hash keys") + } + + fn visit_map(self, mut entries: A) -> Result + where + A: serde::de::MapAccess<'de>, + { + let mut receipts = HashMap::with_capacity(entries.size_hint().unwrap_or_default()); + while let Some((transaction_hash, receipt)) = entries.next_entry()? { + if receipts.insert(transaction_hash, receipt).is_some() { + return Err(serde::de::Error::custom("duplicate receipt key")); + } + } + Ok(receipts) + } + } + + deserializer.deserialize_map(ReceiptsVisitor) +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct RawTransactionReceipt { + #[serde(default)] + logs: Vec, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct RawReceiptLog { + address: Address, + #[serde(default)] + topics: Vec, + data: Bytes, +} diff --git a/tests/public_release_surface.rs b/tests/public_release_surface.rs index 9965131..5f86a9d 100644 --- a/tests/public_release_surface.rs +++ b/tests/public_release_surface.rs @@ -30,6 +30,53 @@ fn published_package_excludes_source_tree_release_audits() { ); } +#[test] +fn raw_json_flashblocks_remain_explicit_and_transport_free() { + let manifest = read("Cargo.toml"); + let feature_section = manifest + .split("[features]") + .nth(1) + .expect("features section") + .split("[dependencies]") + .next() + .expect("feature boundary"); + + assert!( + feature_section.contains("default = [\"reactive\", \"reactive-ws\"]"), + "the raw adapter must remain excluded from default features" + ); + assert!( + feature_section.contains("raw-flashblocks-json = [\"reactive\", \"tokio/sync\"]"), + "the raw adapter should add only the reactive core and its bounded in-process handoff" + ); + let dependencies = manifest + .split("[dependencies]") + .nth(1) + .expect("dependencies section") + .split("[dev-dependencies]") + .next() + .expect("dependency boundary"); + assert!( + !dependencies.contains("tokio-tungstenite =") && !dependencies.contains("tungstenite ="), + "the published raw adapter must not add a source-socket dependency" + ); + assert!( + manifest.contains("name = \"raw_json_flashblocks_subscriber_acceptance\"") + && manifest.contains("required-features = [\"raw-flashblocks-json\", \"reactive-ws\"]"), + "the networked acceptance probe must remain explicit and default-excluded" + ); +} + +#[test] +fn raw_json_acceptance_rejects_a_canonical_chain_mismatch() { + let acceptance = read("examples/raw_json_flashblocks_subscriber_acceptance.rs"); + + assert!( + acceptance.contains("establish_flashblocks_preflight(chain_id)"), + "the live acceptance probe must preflight its reported chain id against the canonical subscriber" + ); +} + #[test] fn alpha2_changelog_explains_flashblock_identity_migration() { let changelog = read("CHANGELOG.md"); diff --git a/tests/raw_json_flashblocks.rs b/tests/raw_json_flashblocks.rs new file mode 100644 index 0000000..78fe7c8 --- /dev/null +++ b/tests/raw_json_flashblocks.rs @@ -0,0 +1,1281 @@ +#![cfg(feature = "raw-flashblocks-json")] + +use alloy_network::Ethereum; +#[cfg(feature = "reactive-ws")] +use alloy_primitives::U256; +use alloy_primitives::{Address, B256}; +use alloy_provider::ProviderBuilder; +#[cfg(feature = "reactive-ws")] +use alloy_rpc_types_eth::Filter; +use alloy_transport::mock::Asserter; +use evm_fork_cache::reactive::{ + AlloySubscriber, FlashblockInvalidationReason, FlashblockUpdate, FlashblockUpdateChannelError, + PreconfirmationMode, ProviderRef, RawJsonFlashblocksAdapter, RawJsonFlashblocksLimits, + SubscriberConfig, SubscriberMode, +}; +#[cfg(feature = "reactive-ws")] +use evm_fork_cache::reactive::{ + ChainStatus, EventSubscriber, LogInterest, ReactiveInput, ReactiveInterest, +}; +use proptest::prelude::*; + +const TX_ONE: B256 = + alloy_primitives::b256!("5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2"); +const TX_TWO: B256 = + alloy_primitives::b256!("f2ee15ea639b73fa3db9b34a245bdfa015c260c598b211bf05a1ecc4b3e3b4f2"); + +fn index_zero() -> Vec { + br#"{ + "payload_id":"0x1111111111111111", + "index":0, + "base":{ + "parent_hash":"0x6464646464646464646464646464646464646464646464646464646464646464", + "block_number":"0x65", + "timestamp":"0x6553f165", + "gas_limit":"0x1c9c380", + "base_fee_per_gas":"0x7", + "fee_recipient":"0xcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcb", + "prev_randao":"0x7777777777777777777777777777777777777777777777777777777777777777" + }, + "diff":{ + "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "transactions":["0x01"] + }, + "metadata":{ + "block_number":101, + "receipts":{ + "0x5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2":{ + "type":"0x2", + "status":"0x1", + "cumulativeGasUsed":"0x5208", + "logs":[{ + "address":"0x4242424242424242424242424242424242424242", + "topics":["0x4343434343434343434343434343434343434343434343434343434343434343"], + "data":"0x0102" + }] + } + } + } + }"# + .to_vec() +} + +fn index_one(receipt_transaction: B256) -> Vec { + format!( + r#"{{ + "payload_id":"0x1111111111111111", + "index":1, + "diff":{{ + "state_root":"0xabababababababababababababababababababababababababababababababab", + "block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "transactions":["0x02"] + }}, + "metadata":{{ + "block_number":"0x65", + "receipts":{{ + "{receipt_transaction:#x}":{{ + "logs":[ + {{ + "address":"0x4444444444444444444444444444444444444444", + "topics":[], + "data":"0x03" + }}, + {{ + "address":"0x4545454545454545454545454545454545454545", + "topics":[], + "data":"0x04" + }} + ] + }} + }} + }} + }}"# + ) + .into_bytes() +} + +fn index_two(receipt_transaction: B256) -> Vec { + format!( + r#"{{ + "payload_id":"0x1111111111111111", + "index":2, + "diff":{{ + "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "transactions":["0x03"] + }}, + "metadata":{{ + "block_number":"0x65", + "receipts":{{ + "{receipt_transaction:#x}":{{ + "logs":[] + }} + }} + }} + }}"# + ) + .into_bytes() +} + +fn conflicting_index_one(receipt_transaction: B256) -> Vec { + format!( + r#"{{ + "payload_id":"0x1111111111111111", + "index":1, + "diff":{{ + "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "transactions":["0x03"] + }}, + "metadata":{{ + "block_number":"0x65", + "receipts":{{ + "{receipt_transaction:#x}":{{ + "logs":[] + }} + }} + }} + }}"# + ) + .into_bytes() +} + +fn alternate_index_zero() -> Vec { + let third_transaction = alloy_primitives::keccak256([3_u8]); + String::from_utf8(index_zero()) + .expect("index-zero fixture is UTF-8") + .replace("\"transactions\":[\"0x01\"]", "\"transactions\":[\"0x03\"]") + .replace(&format!("{TX_ONE:#x}"), &format!("{third_transaction:#x}")) + .into_bytes() +} + +fn alternate_index_one(receipt_transaction: B256) -> Vec { + format!( + r#"{{ + "payload_id":"0x1111111111111111", + "index":1, + "diff":{{ + "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash":"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "transactions":["0x04"] + }}, + "metadata":{{ + "block_number":"0x65", + "receipts":{{ + "{receipt_transaction:#x}":{{ + "logs":[] + }} + }} + }} + }}"# + ) + .into_bytes() +} + +fn index_zero_for_block(block_number: u64) -> Vec { + String::from_utf8(index_zero()) + .expect("index-zero fixture is UTF-8") + .replace( + "\"block_number\":\"0x65\"", + &format!("\"block_number\":\"0x{block_number:x}\""), + ) + .replace( + "\"block_number\":101", + &format!("\"block_number\":{block_number}"), + ) + .into_bytes() +} + +fn index_one_for_block(block_number: u64) -> Vec { + String::from_utf8(index_one(TX_TWO)) + .expect("index-one fixture is UTF-8") + .replace( + "\"block_number\":\"0x65\"", + &format!("\"block_number\":\"0x{block_number:x}\""), + ) + .into_bytes() +} + +fn adapter(endpoint: &str, generation: u64) -> RawJsonFlashblocksAdapter { + RawJsonFlashblocksAdapter::new(ProviderRef::new(endpoint, generation)) +} + +#[tokio::test] +async fn bounded_update_sender_remains_external_to_subscriber_ownership() { + let source = ProviderRef::new("raw-json", 7); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + let sender = subscriber + .open_external_flashblock_update_channel(1) + .expect("bounded update channel"); + let mut adapter = RawJsonFlashblocksAdapter::new(source.clone()); + let update = adapter + .ingest_json(&index_zero()) + .expect("valid raw frame") + .expect("standardized update"); + + let _receipt = sender.try_send(update.clone()).expect("first queue slot"); + assert!(matches!( + sender.try_send(update.clone()), + Err(FlashblockUpdateChannelError::Full) + )); + + let mut other = RawJsonFlashblocksAdapter::new(ProviderRef::new("other-endpoint", 1)); + let other_update = other + .ingest_json(&index_zero()) + .expect("other raw frame") + .expect("other standardized update"); + assert!(matches!( + sender.try_send(other_update), + Err(FlashblockUpdateChannelError::UnexpectedEndpoint) + )); + + drop(subscriber); + assert_eq!( + sender.send(update).await, + Err(FlashblockUpdateChannelError::Closed) + ); +} + +#[test] +fn update_channel_configuration_is_single_and_nonzero() { + let source = ProviderRef::new("raw-json", 7); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source) + .expect("configure external source"); + + assert!( + subscriber + .open_external_flashblock_update_channel(0) + .expect_err("zero capacity") + .to_string() + .contains("greater than zero") + ); + subscriber + .open_external_flashblock_update_channel(1) + .expect("first channel"); + assert!( + subscriber + .open_external_flashblock_update_channel(1) + .expect_err("second channel") + .to_string() + .contains("already opened") + ); +} + +#[test] +fn direct_standardized_ingest_rejects_an_index_gap() { + let source = ProviderRef::new("raw-json", 7); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + let mut adapter = RawJsonFlashblocksAdapter::new(source); + + let first = adapter + .ingest_json(&index_zero()) + .expect("valid index zero") + .expect("index zero snapshot"); + let _skipped = adapter + .ingest_json(&index_one(TX_TWO)) + .expect("valid index one") + .expect("index one snapshot"); + let third_transaction = alloy_primitives::keccak256([3_u8]); + let gap = adapter + .ingest_json(&index_two(third_transaction)) + .expect("valid index two") + .expect("index two snapshot"); + + subscriber + .ingest_flashblock_update(first) + .expect("index zero is accepted"); + assert!( + subscriber + .ingest_flashblock_update(gap) + .expect_err("subscriber must reject a skipped index") + .to_string() + .contains("index") + ); +} + +#[test] +fn direct_standardized_ingest_requires_index_zero_for_a_new_payload() { + let source = ProviderRef::new("raw-json", 7); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let _ = adapter + .ingest_json(&index_zero()) + .expect("valid index zero") + .expect("index zero snapshot"); + let starts_late = adapter + .ingest_json(&index_one(TX_TWO)) + .expect("valid index one") + .expect("index one snapshot"); + + assert!( + subscriber + .ingest_flashblock_update(starts_late) + .expect_err("a new payload cannot start after index zero") + .to_string() + .contains("begin at index zero") + ); +} + +#[test] +fn direct_standardized_ingest_rejects_an_index_regression() { + let source = ProviderRef::new("raw-json", 7); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let first = adapter + .ingest_json(&index_zero()) + .expect("valid index zero") + .expect("index zero snapshot"); + let repeated_first = first.clone(); + let next = adapter + .ingest_json(&index_one(TX_TWO)) + .expect("valid index one") + .expect("index one snapshot"); + subscriber + .ingest_flashblock_update(first) + .expect("index zero is accepted"); + subscriber + .ingest_flashblock_update(next) + .expect("index one is accepted"); + + assert!( + subscriber + .ingest_flashblock_update(repeated_first) + .expect_err("an older index cannot replace a newer snapshot") + .to_string() + .contains("regressed") + ); +} + +#[test] +fn direct_standardized_ingest_accepts_an_exact_duplicate_as_a_no_op() { + let source = ProviderRef::new("raw-json", 7); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let first = adapter + .ingest_json(&index_zero()) + .expect("valid index zero") + .expect("index zero snapshot"); + subscriber + .ingest_flashblock_update(first.clone()) + .expect("index zero is accepted"); + subscriber + .ingest_flashblock_update(first) + .expect("an exact duplicate is idempotent"); +} + +#[test] +fn direct_standardized_ingest_rejects_changed_content_at_the_same_index() { + let source = ProviderRef::new("raw-json", 7); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + let mut first_source = RawJsonFlashblocksAdapter::new(source.clone()); + let first = first_source + .ingest_json(&index_zero()) + .expect("valid index zero") + .expect("index zero snapshot"); + let index_one = first_source + .ingest_json(&index_one(TX_TWO)) + .expect("valid index one") + .expect("index one snapshot"); + subscriber + .ingest_flashblock_update(first) + .expect("index zero is accepted"); + subscriber + .ingest_flashblock_update(index_one) + .expect("index one is accepted"); + + let mut conflicting_source = RawJsonFlashblocksAdapter::new(source); + let _ = conflicting_source + .ingest_json(&index_zero()) + .expect("valid alternate index zero") + .expect("alternate index zero snapshot"); + let third_transaction = alloy_primitives::keccak256([3_u8]); + let conflict = conflicting_source + .ingest_json(&conflicting_index_one(third_transaction)) + .expect("independently valid conflicting index one") + .expect("conflicting index one snapshot"); + + assert!( + subscriber + .ingest_flashblock_update(conflict) + .expect_err("same-index content changes must fail closed") + .to_string() + .contains("same index") + ); +} + +#[test] +fn direct_standardized_ingest_rejects_non_prefix_cumulative_membership() { + let source = ProviderRef::new("raw-json", 7); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + let mut accepted_source = RawJsonFlashblocksAdapter::new(source.clone()); + let first = accepted_source + .ingest_json(&index_zero()) + .expect("valid index zero") + .expect("index zero snapshot"); + subscriber + .ingest_flashblock_update(first) + .expect("index zero is accepted"); + + let mut divergent_source = RawJsonFlashblocksAdapter::new(source); + let _ = divergent_source + .ingest_json(&alternate_index_zero()) + .expect("valid divergent index zero") + .expect("divergent index zero snapshot"); + let fourth_transaction = alloy_primitives::keccak256([4_u8]); + let non_prefix = divergent_source + .ingest_json(&alternate_index_one(fourth_transaction)) + .expect("independently valid divergent index one") + .expect("divergent index one snapshot"); + + assert!( + subscriber + .ingest_flashblock_update(non_prefix) + .expect_err("cumulative membership must retain the prior prefix") + .to_string() + .contains("prefix") + ); +} + +#[test] +fn direct_standardized_ingest_rejects_base_identity_changes_within_a_payload() { + let source = ProviderRef::new("raw-json", 7); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + let mut accepted_source = RawJsonFlashblocksAdapter::new(source.clone()); + let first = accepted_source + .ingest_json(&index_zero()) + .expect("valid index zero") + .expect("index zero snapshot"); + subscriber + .ingest_flashblock_update(first) + .expect("index zero is accepted"); + + let mut altered_source = RawJsonFlashblocksAdapter::new(source); + let _ = altered_source + .ingest_json(&index_zero_for_block(102)) + .expect("valid alternate base") + .expect("alternate index zero snapshot"); + let altered_base = altered_source + .ingest_json(&index_one_for_block(102)) + .expect("valid alternate index one") + .expect("alternate index one snapshot"); + + assert!( + subscriber + .ingest_flashblock_update(altered_base) + .expect_err("base identity must stay stable within a payload") + .to_string() + .contains("base identity") + ); +} + +#[test] +fn direct_standardized_ingest_rejects_delta_logs_from_prior_transactions() { + let source = ProviderRef::new("raw-json", 7); + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Auto, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let first = adapter + .ingest_json(&index_zero()) + .expect("valid index zero") + .expect("index zero snapshot"); + subscriber + .ingest_flashblock_update(first) + .expect("index zero is accepted"); + + let mut next = adapter + .ingest_json(&index_one(TX_TWO)) + .expect("valid index one") + .expect("index one snapshot"); + let FlashblockUpdate::Snapshot(snapshot) = &mut next else { + panic!("expected snapshot") + }; + snapshot.logs[0].transaction_hash = Some(TX_ONE); + snapshot.logs[0].transaction_index = Some(0); + snapshot.logs[0].log_index = Some(99); + + assert!( + subscriber + .ingest_flashblock_update(next) + .expect_err("delta logs must come from newly appended transactions") + .to_string() + .contains("newly appended") + ); +} + +#[test] +fn indexed_delta_becomes_a_standard_flashblock_update() { + let provider = ProviderRef::new("raw-json", 7); + let mut adapter = RawJsonFlashblocksAdapter::new(provider.clone()); + + let update = adapter + .ingest_json(&index_zero()) + .expect("valid raw Flashblock") + .expect("first index publishes a batch"); + let FlashblockUpdate::Snapshot(batch) = update else { + panic!("expected standardized snapshot") + }; + + assert_eq!(batch.flashblock.provider, provider); + assert_eq!(batch.flashblock.payload_id, Some([0x11; 8].into())); + assert_eq!(batch.flashblock.index, Some(0)); + assert_eq!(batch.flashblock.block_number, 101); + assert_eq!(batch.flashblock.transaction_hashes, vec![TX_ONE]); + assert_ne!(batch.flashblock.content_hash, B256::ZERO); + assert_eq!(batch.logs.len(), 1); + assert_eq!(batch.logs[0].address(), Address::repeat_byte(0x42)); + assert_eq!(batch.logs[0].transaction_hash, Some(TX_ONE)); + assert_eq!(batch.logs[0].transaction_index, Some(0)); + assert_eq!(batch.logs[0].log_index, Some(0)); + assert_eq!( + batch.logs[0].block_hash, + Some(batch.flashblock.content_hash) + ); +} + +#[test] +fn converter_is_chain_neutral_and_carries_only_caller_provenance() { + let mut first = adapter("chain-a-provider", 2); + let mut second = adapter("chain-b-provider", 9); + + let FlashblockUpdate::Snapshot(first) = first + .ingest_json(&index_zero()) + .expect("first provider frame") + .expect("first provider snapshot") + else { + panic!("expected snapshot") + }; + let FlashblockUpdate::Snapshot(second) = second + .ingest_json(&index_zero()) + .expect("second provider frame") + .expect("second provider snapshot") + else { + panic!("expected snapshot") + }; + + assert_eq!( + first.flashblock.provider, + ProviderRef::new("chain-a-provider", 2) + ); + assert_eq!( + second.flashblock.provider, + ProviderRef::new("chain-b-provider", 9) + ); + assert_ne!( + first.flashblock.content_hash, + second.flashblock.content_hash + ); +} + +#[test] +fn rejected_delta_does_not_poison_a_corrected_retry() { + let mut adapter = adapter("raw-json", 7); + adapter + .ingest_json(&index_zero()) + .expect("valid index zero"); + + let error = adapter + .ingest_json(&index_one(TX_ONE)) + .expect_err("receipt membership mismatch"); + assert!(error.to_string().contains("receipt-map membership")); + + let FlashblockUpdate::Snapshot(batch) = adapter + .ingest_json(&index_one(TX_TWO)) + .expect("corrected retry remains admissible") + .expect("corrected retry publishes") + else { + panic!("expected snapshot") + }; + assert_eq!(batch.flashblock.transaction_hashes, vec![TX_ONE, TX_TWO]); + assert_eq!(batch.logs.len(), 2); + assert_eq!(batch.logs[0].transaction_index, Some(1)); + assert_eq!(batch.logs[0].log_index, Some(1)); + assert_eq!(batch.logs[1].log_index, Some(2)); +} + +#[test] +fn semantic_duplicate_is_idempotent_across_json_serializations() { + let mut adapter = adapter("raw-json", 7); + let frame = index_zero(); + adapter.ingest_json(&frame).expect("first delivery"); + let compact = serde_json::to_vec( + &serde_json::from_slice::(&frame).expect("fixture JSON"), + ) + .expect("compact fixture"); + + assert!(adapter.ingest_json(&compact).expect("duplicate").is_none()); +} + +#[test] +fn duplicate_receipt_map_keys_are_rejected_before_normalization() { + let receipt_key = format!("{TX_ONE:#x}"); + let original_key = format!("\"{receipt_key}\":{{"); + let uppercase_key = format!("0x{}", receipt_key[2..].to_ascii_uppercase()); + + for duplicate_key in [&receipt_key, &uppercase_key] { + let repeated_key = format!("\"{receipt_key}\":{{\"logs\":[]}},\"{duplicate_key}\":{{"); + let frame = String::from_utf8(index_zero()) + .expect("index-zero fixture is UTF-8") + .replace(&original_key, &repeated_key); + let mut adapter = adapter("raw-json", 7); + + let error = adapter + .ingest_json(frame.as_bytes()) + .expect_err("duplicate receipt key must be rejected"); + assert!(error.to_string().contains("duplicate receipt key")); + } +} + +#[test] +fn sequence_failures_emit_standard_invalidations() { + let mut adapter = adapter("raw-json", 7); + adapter.ingest_json(&index_zero()).expect("first delivery"); + let gap = String::from_utf8(index_one(TX_TWO)) + .expect("fixture UTF-8") + .replace("\"index\":1", "\"index\":2") + .into_bytes(); + + let FlashblockUpdate::Invalidated(invalidation) = adapter + .ingest_json(&gap) + .expect("gap is an invalidation") + .expect("gap is observable") + else { + panic!("expected invalidation") + }; + assert_eq!(invalidation.provider, ProviderRef::new("raw-json", 7)); + assert_eq!(invalidation.reason, FlashblockInvalidationReason::IndexGap); + assert!( + adapter + .ingest_json(&index_one(TX_TWO)) + .expect("ignored remainder") + .is_none() + ); + + let replacement = String::from_utf8(index_zero()) + .expect("fixture UTF-8") + .replace("0x1111111111111111", "0x2222222222222222") + .into_bytes(); + assert!(matches!( + adapter.ingest_json(&replacement).expect("next payload"), + Some(FlashblockUpdate::Snapshot(_)) + )); +} + +#[test] +fn joining_after_index_zero_invalidates_instead_of_inventing_a_base() { + let mut adapter = adapter("raw-json", 7); + let FlashblockUpdate::Invalidated(invalidation) = adapter + .ingest_json(&index_one(TX_TWO)) + .expect("late join becomes an invalidation") + .expect("late join is observable") + else { + panic!("expected invalidation") + }; + assert_eq!( + invalidation.reason, + FlashblockInvalidationReason::MissingInitialIndex + ); +} + +#[test] +fn every_delta_must_retain_the_index_zero_block_number() { + let mut adapter = adapter("raw-json", 7); + adapter.ingest_json(&index_zero()).expect("index zero"); + let wrong_block = String::from_utf8(index_one(TX_TWO)) + .expect("fixture UTF-8") + .replace("\"block_number\":\"0x65\"", "\"block_number\":\"0x66\"") + .into_bytes(); + assert!( + adapter + .ingest_json(&wrong_block) + .expect_err("block drift") + .to_string() + .contains("block numbers disagree") + ); + assert!(matches!( + adapter + .ingest_json(&index_one(TX_TWO)) + .expect("correct retry"), + Some(FlashblockUpdate::Snapshot(_)) + )); +} + +#[test] +fn caller_reset_revokes_the_active_generation_without_reconnecting() { + let mut adapter = adapter("raw-json", 7); + adapter.ingest_json(&index_zero()).expect("first delivery"); + + let FlashblockUpdate::Invalidated(invalidation) = adapter + .reset(ProviderRef::new("raw-json", 8)) + .expect("valid source transition") + .expect("active payload is revoked") + else { + panic!("expected invalidation") + }; + assert_eq!(invalidation.provider, ProviderRef::new("raw-json", 7)); + assert_eq!( + invalidation.reason, + FlashblockInvalidationReason::SourceReset + ); + + let FlashblockUpdate::Snapshot(replacement) = adapter + .ingest_json(&index_zero()) + .expect("new generation frame") + .expect("new generation snapshot") + else { + panic!("expected replacement snapshot") + }; + assert_eq!( + replacement.flashblock.provider, + ProviderRef::new("raw-json", 8) + ); +} + +#[test] +fn reset_rejects_endpoint_changes_and_non_increasing_generations() { + let mut adapter = adapter("raw-json", 7); + adapter.ingest_json(&index_zero()).expect("first delivery"); + + for replacement in [ + ProviderRef::new("raw-json", 7), + ProviderRef::new("raw-json", 6), + ProviderRef::new("other-source", 8), + ] { + assert!( + adapter + .reset(replacement) + .expect_err("ambiguous transition") + .to_string() + .contains("source transition") + ); + } + + let FlashblockUpdate::Snapshot(next) = adapter + .ingest_json(&index_one(TX_TWO)) + .expect("rejected reset preserves active state") + .expect("next delta remains admissible") + else { + panic!("expected snapshot") + }; + assert_eq!(next.flashblock.provider, ProviderRef::new("raw-json", 7)); + assert_eq!(next.flashblock.index, Some(1)); +} + +#[test] +fn zero_resource_bounds_are_rejected_at_construction() { + let mut limits = RawJsonFlashblocksLimits::default(); + limits.max_frame_bytes = 0; + let error = RawJsonFlashblocksAdapter::with_limits(ProviderRef::new("raw-json", 1), limits) + .expect_err("zero frame limit must be rejected"); + assert!(error.to_string().contains("max_frame_bytes")); +} + +#[test] +fn conflicting_duplicate_revokes_the_active_payload() { + let mut adapter = adapter("raw-json", 7); + adapter.ingest_json(&index_zero()).expect("first delivery"); + let conflicting = String::from_utf8(index_zero()) + .expect("fixture UTF-8") + .replace("\"data\":\"0x0102\"", "\"data\":\"0x99\"") + .into_bytes(); + + let FlashblockUpdate::Invalidated(invalidation) = adapter + .ingest_json(&conflicting) + .expect("conflict becomes an invalidation") + .expect("conflict is observable") + else { + panic!("expected invalidation") + }; + assert_eq!( + invalidation.reason, + FlashblockInvalidationReason::ConflictingDuplicate + ); + assert_eq!(invalidation.payload_id, [0x11_u8; 8]); +} + +#[test] +fn a_new_payload_missing_index_zero_revokes_the_previous_payload() { + let mut adapter = adapter("raw-json", 7); + adapter.ingest_json(&index_zero()).expect("first delivery"); + let next_payload_gap = String::from_utf8(index_one(TX_TWO)) + .expect("fixture UTF-8") + .replace("0x1111111111111111", "0x2222222222222222") + .into_bytes(); + + let FlashblockUpdate::Invalidated(invalidation) = adapter + .ingest_json(&next_payload_gap) + .expect("missing base becomes an invalidation") + .expect("missing base is observable") + else { + panic!("expected invalidation") + }; + assert_eq!( + invalidation.reason, + FlashblockInvalidationReason::MissingInitialIndex + ); + assert_eq!( + invalidation.payload_id, [0x11_u8; 8], + "the subscriber can revoke the exact payload that was previously published" + ); +} + +#[test] +fn rejected_replacement_frame_preserves_the_active_payload_for_explicit_reset() { + let mut adapter = adapter("raw-json", 7); + adapter.ingest_json(&index_zero()).expect("first delivery"); + let malformed_replacement = String::from_utf8(index_zero()) + .expect("fixture UTF-8") + .replace("0x1111111111111111", "0x2222222222222222") + .replace("\"block_number\":101", "\"block_number\":102") + .into_bytes(); + + assert!(adapter.ingest_json(&malformed_replacement).is_err()); + let FlashblockUpdate::Invalidated(invalidation) = adapter + .reset(ProviderRef::new("raw-json", 8)) + .expect("valid source transition") + .expect("the last published payload remains revocable") + else { + panic!("expected invalidation") + }; + assert_eq!(invalidation.payload_id, [0x11_u8; 8]); +} + +#[test] +fn duplicate_transaction_across_deltas_is_rejected_without_advancing_sequence() { + let mut adapter = adapter("raw-json", 7); + adapter.ingest_json(&index_zero()).expect("first delivery"); + let duplicate = String::from_utf8(index_one(TX_ONE)) + .expect("fixture UTF-8") + .replace("\"transactions\":[\"0x02\"]", "\"transactions\":[\"0x01\"]") + .into_bytes(); + assert!( + adapter + .ingest_json(&duplicate) + .expect_err("duplicate cumulative member") + .to_string() + .contains("more than one indexed delta") + ); + + let FlashblockUpdate::Snapshot(corrected) = adapter + .ingest_json(&index_one(TX_TWO)) + .expect("corrected index remains admissible") + .expect("corrected index publishes") + else { + panic!("expected snapshot") + }; + assert_eq!(corrected.flashblock.index, Some(1)); +} + +#[test] +fn duplicate_transaction_inside_one_delta_is_rejected_without_advancing_sequence() { + let mut adapter = adapter("raw-json", 7); + let duplicate = String::from_utf8(index_zero()) + .expect("fixture UTF-8") + .replace( + "\"transactions\":[\"0x01\"]", + "\"transactions\":[\"0x01\",\"0x01\"]", + ) + .into_bytes(); + + assert!( + adapter + .ingest_json(&duplicate) + .expect_err("duplicate transaction inside a delta") + .to_string() + .contains("duplicate hash") + ); + assert!(matches!( + adapter.ingest_json(&index_zero()).expect("corrected index"), + Some(FlashblockUpdate::Snapshot(_)) + )); +} + +#[test] +fn configured_frame_transaction_and_log_bounds_fail_closed() { + let source = ProviderRef::new("raw-json", 1); + let mut frame_limits = RawJsonFlashblocksLimits::default(); + frame_limits.max_frame_bytes = index_zero().len() - 1; + let mut frame_limited = RawJsonFlashblocksAdapter::with_limits(source.clone(), frame_limits) + .expect("valid frame limit"); + assert!( + frame_limited + .ingest_json(&index_zero()) + .expect_err("oversized frame") + .to_string() + .contains("byte limit") + ); + + let mut transaction_limits = RawJsonFlashblocksLimits::default(); + transaction_limits.max_transactions_per_payload = 1; + let mut transaction_limited = + RawJsonFlashblocksAdapter::with_limits(source.clone(), transaction_limits) + .expect("valid transaction limit"); + transaction_limited + .ingest_json(&index_zero()) + .expect("first transaction"); + assert!( + transaction_limited + .ingest_json(&index_one(TX_TWO)) + .expect_err("second cumulative transaction") + .to_string() + .contains("transaction count") + ); + + let mut log_limits = RawJsonFlashblocksLimits::default(); + log_limits.max_logs_per_payload = 1; + let mut log_limited = + RawJsonFlashblocksAdapter::with_limits(source, log_limits).expect("valid log limit"); + log_limited.ingest_json(&index_zero()).expect("first log"); + assert!( + log_limited + .ingest_json(&index_one(TX_TWO)) + .expect_err("second cumulative log") + .to_string() + .contains("log count") + ); +} + +#[test] +fn receipt_logs_with_more_than_four_topics_are_rejected() { + let mut adapter = adapter("raw-json", 7); + let topics = format!( + "\"topics\":[\"{0:#x}\",\"{1:#x}\",\"{2:#x}\",\"{3:#x}\",\"{4:#x}\"]", + B256::repeat_byte(1), + B256::repeat_byte(2), + B256::repeat_byte(3), + B256::repeat_byte(4), + B256::repeat_byte(5), + ); + let frame = String::from_utf8(index_zero()) + .expect("fixture UTF-8") + .replace( + "\"topics\":[\"0x4343434343434343434343434343434343434343434343434343434343434343\"]", + &topics, + ) + .into_bytes(); + assert!( + adapter + .ingest_json(&frame) + .expect_err("too many topics") + .to_string() + .contains("more than four topics") + ); +} + +proptest! { + #[test] + fn arbitrary_application_frames_never_panic_or_prevent_explicit_recovery( + frame in proptest::collection::vec(any::(), 0..4096), + ) { + let mut adapter = adapter("raw-json", 1); + let _ = adapter.ingest_json(&frame); + let _ = adapter + .reset(ProviderRef::new("raw-json", 2)) + .expect("valid source transition"); + prop_assert!(matches!( + adapter.ingest_json(&index_zero()), + Ok(Some(FlashblockUpdate::Snapshot(_))) + )); + } +} + +#[tokio::test] +#[cfg(feature = "reactive-ws")] +async fn standardized_updates_enter_the_existing_preconfirmation_pipeline() { + let asserter = Asserter::new(); + asserter.push_success(&U256::from(10)); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let source = ProviderRef::new("raw-json", 7); + let mut subscriber = evm_fork_cache::reactive::AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + subscriber + .register_interests(&[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new() + .address(Address::repeat_byte(0x42)) + .event_signature(B256::repeat_byte(0x43)), + local_matcher: None, + route_key: None, + })]) + .await + .expect("register raw update interest"); + + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let update = adapter + .ingest_json(&index_zero()) + .expect("decode raw frame") + .expect("publish raw frame"); + subscriber + .ingest_flashblock_update(update) + .expect("ingest standardized update"); + + let batch = subscriber + .next_scoped_batch() + .await + .expect("subscriber remains healthy") + .expect("preconfirmed batch"); + assert_eq!(batch.records().len(), 1); + assert!(batch.records()[0].scope().is_preconfirmed()); + assert!(matches!(batch.records()[0].input, ReactiveInput::Log(_))); + assert!(matches!( + batch.records()[0].context.chain_status, + ChainStatus::Preconfirmed { ref flashblock } + if flashblock.provider == ProviderRef::new("raw-json", 7) + )); + assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0); + + let reset = adapter + .reset(ProviderRef::new("raw-json", 8)) + .expect("valid source transition") + .expect("active source reset invalidates"); + subscriber + .ingest_flashblock_update(reset) + .expect("ingest source reset"); + let invalidation = subscriber + .next_scoped_batch() + .await + .expect("subscriber remains healthy") + .expect("invalidation batch"); + assert!(invalidation.preconfirmation_invalidated()); + assert!(invalidation.records().is_empty()); + + let next = adapter + .ingest_json(&index_zero()) + .expect("next provider generation frame") + .expect("next provider generation publishes"); + subscriber + .ingest_flashblock_update(next) + .expect("ingest next provider generation"); + let next_batch = subscriber + .next_scoped_batch() + .await + .expect("subscriber remains healthy") + .expect("replacement batch"); + assert!(matches!( + next_batch.records()[0].context.chain_status, + ChainStatus::Preconfirmed { ref flashblock } + if flashblock.provider == ProviderRef::new("raw-json", 8) + )); + + let mut stale = RawJsonFlashblocksAdapter::new(ProviderRef::new("raw-json", 7)); + subscriber + .ingest_flashblock_update( + stale + .ingest_json(&index_zero()) + .expect("stale frame decodes") + .expect("stale snapshot"), + ) + .expect("stale snapshot is ignored"); + let stale_invalidation = stale + .reset(ProviderRef::new("raw-json", 9)) + .expect("valid stale-source transition") + .expect("stale source invalidation"); + subscriber + .ingest_flashblock_update(stale_invalidation) + .expect("stale invalidation is ignored"); + + let current_invalidation = adapter + .reset(ProviderRef::new("raw-json", 9)) + .expect("valid current-source transition") + .expect("current generation invalidation"); + subscriber + .ingest_flashblock_update(current_invalidation) + .expect("current invalidation is accepted"); + let current_invalidation = subscriber + .next_scoped_batch() + .await + .expect("subscriber remains healthy") + .expect("current invalidation batch"); + assert!(current_invalidation.preconfirmation_invalidated()); + assert!(current_invalidation.records().is_empty()); +} + +#[tokio::test] +#[cfg(feature = "reactive-ws")] +async fn subscriber_rejects_tampered_or_wrong_source_snapshots_without_queue_mutation() { + let asserter = Asserter::new(); + asserter.push_success(&U256::from(10)); + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + let source = ProviderRef::new("raw-json", 7); + let mut subscriber = evm_fork_cache::reactive::AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Required, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + subscriber + .register_interests(&[ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new() + .address(Address::repeat_byte(0x42)) + .event_signature(B256::repeat_byte(0x43)), + local_matcher: None, + route_key: None, + })]) + .await + .expect("register raw update interest"); + + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let valid = adapter + .ingest_json(&index_zero()) + .expect("decode raw frame") + .expect("publish raw frame"); + let mut tampered = valid.clone(); + let FlashblockUpdate::Snapshot(snapshot) = &mut tampered else { + panic!("expected snapshot") + }; + snapshot.flashblock.content_hash = B256::repeat_byte(0xff); + assert!( + subscriber + .ingest_flashblock_update(tampered) + .expect_err("tampered commitment") + .to_string() + .contains("content commitment") + ); + + let wrong_source = RawJsonFlashblocksAdapter::new(ProviderRef::new("other-source", 7)); + let mut wrong_source = wrong_source; + assert!( + subscriber + .ingest_flashblock_update( + wrong_source + .ingest_json(&index_zero()) + .expect("decode other source") + .expect("other source snapshot") + ) + .expect_err("wrong source endpoint") + .to_string() + .contains("unexpected provider endpoint") + ); + + subscriber + .ingest_flashblock_update(valid) + .expect("valid snapshot remains admissible"); + let batch = subscriber + .next_scoped_batch() + .await + .expect("subscriber remains healthy") + .expect("only valid snapshot was queued"); + assert_eq!(batch.records().len(), 1); + assert!(batch.records()[0].scope().is_preconfirmed()); +} diff --git a/tests/raw_json_flashblocks_runtime.rs b/tests/raw_json_flashblocks_runtime.rs new file mode 100644 index 0000000..0e9413e --- /dev/null +++ b/tests/raw_json_flashblocks_runtime.rs @@ -0,0 +1,482 @@ +//! Offline end-to-end coverage for the optional raw JSON Flashblocks profile. +//! +//! A receipt-enriched application frame is normalized, admitted by a real +//! `AlloySubscriber`, applied to a real speculative `ReactiveRuntime` cache +//! branch, revoked, replaced, and finally reconciled by an ordinary canonical +//! subscriber batch. All providers are mocked; the speculative path performs +//! no request/response I/O. +#![cfg(all(feature = "raw-flashblocks-json", feature = "reactive-polling"))] + +mod common; + +use std::sync::Arc; + +use alloy_network::Ethereum; +use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256}; +use alloy_provider::ProviderBuilder; +use alloy_rpc_types_eth::{Block, Filter, Header, Log}; +use alloy_transport::mock::Asserter; +use anyhow::{Result, bail}; +use common::{install_mock_erc20, setup_cache_with_asserter}; +use evm_fork_cache::StateUpdate; +use evm_fork_cache::events::StateView; +use evm_fork_cache::reactive::{ + AlloySubscriber, BlockRef, ChainStatus, DeliveryScope, EventSubscriber, FlashblockRef, + FlashblockUpdate, HandlerError, HandlerId, HandlerOutcome, InputSource, LogInterest, + PreconfirmationMode, ProviderRef, RawJsonFlashblocksAdapter, ReactiveConfig, ReactiveContext, + ReactiveEffect, ReactiveHandler, ReactiveInput, ReactiveInputBatch, ReactiveInputRecord, + ReactiveInterest, ReactiveRuntime, StateEffectQuality, SubscriberBackfill, SubscriberConfig, + SubscriberMode, +}; + +const POOL_SLOT: u64 = 0; +const CHAIN_ID: u64 = 1; +const BLOCK_NUMBER: u64 = 101; +const RAW_TRANSACTION: &str = "0x01"; +const TRANSACTION_HASH: B256 = + alloy_primitives::b256!("5fe7f977e71dba2ea1a68e21057beebb9be2ac30c6410aa38d4f3fbe41dcffd2"); + +struct PoolHandler { + pool: Address, +} + +impl ReactiveHandler for PoolHandler { + fn id(&self) -> HandlerId { + HandlerId::new("raw-json-pool") + } + + fn interests(&self) -> Vec { + vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(self.pool), + local_matcher: None, + route_key: None, + })] + } + + fn handle( + &self, + _ctx: &ReactiveContext, + input: &ReactiveInput, + _state: &dyn StateView, + ) -> Result { + let ReactiveInput::Log(log) = input else { + return Ok(HandlerOutcome::empty(StateEffectQuality::NoStateEffect)); + }; + Ok(HandlerOutcome { + effects: vec![ReactiveEffect::StateUpdate(StateUpdate::slot( + log.address(), + U256::from(POOL_SLOT), + U256::from_be_slice(log.data().data.as_ref()), + ))], + quality: StateEffectQuality::ExactFromInput, + tags: vec![], + }) + } +} + +fn raw_frame(pool: Address, topic: B256, value: U256) -> Vec { + format!( + r#"{{ + "payload_id":"0x1111111111111111", + "index":0, + "base":{{ + "parent_hash":"0x6464646464646464646464646464646464646464646464646464646464646464", + "block_number":"0x65", + "timestamp":"0x6553f165", + "gas_limit":"0x1c9c380", + "base_fee_per_gas":"0x7" + }}, + "diff":{{ + "state_root":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "block_hash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "transactions":["{RAW_TRANSACTION}"] + }}, + "metadata":{{ + "block_number":{BLOCK_NUMBER}, + "receipts":{{ + "{TRANSACTION_HASH:#x}":{{ + "logs":[{{ + "address":"{pool:#x}", + "topics":["{topic:#x}"], + "data":"0x{value:064x}" + }}] + }} + }} + }} + }}"#, + ) + .into_bytes() +} + +fn canonical_log(pool: Address, topic: B256, value: U256) -> Log { + Log { + inner: PrimitiveLog::new_unchecked( + pool, + vec![topic], + Bytes::from(value.to_be_bytes::<32>().to_vec()), + ), + block_hash: Some(B256::repeat_byte(BLOCK_NUMBER as u8)), + block_number: Some(BLOCK_NUMBER), + block_timestamp: Some(1_700_000_000 + BLOCK_NUMBER), + transaction_hash: Some(TRANSACTION_HASH), + transaction_index: Some(0), + log_index: Some(0), + removed: false, + } +} + +fn canonical_block() -> Block { + Block::empty(Header { + hash: B256::repeat_byte(BLOCK_NUMBER as u8), + inner: alloy_consensus::Header { + number: BLOCK_NUMBER, + parent_hash: B256::repeat_byte(BLOCK_NUMBER.saturating_sub(1) as u8), + timestamp: 1_700_000_000 + BLOCK_NUMBER, + ..Default::default() + }, + total_difficulty: None, + size: None, + }) +} + +fn canonical_parent() -> BlockRef { + BlockRef { + number: BLOCK_NUMBER - 1, + hash: B256::repeat_byte((BLOCK_NUMBER - 1) as u8), + parent_hash: Some(B256::repeat_byte((BLOCK_NUMBER - 2) as u8)), + timestamp: Some(1_700_000_000 + BLOCK_NUMBER - 1), + } +} + +fn standardized_preview(pool: Address, topic: B256, value: U256) -> Result<(FlashblockRef, Log)> { + let mut adapter = RawJsonFlashblocksAdapter::new(ProviderRef::new("raw-json", 1)); + let update = adapter + .ingest_json(&raw_frame(pool, topic, value))? + .ok_or_else(|| anyhow::anyhow!("expected standardized preview"))?; + let FlashblockUpdate::Snapshot(snapshot) = update else { + bail!("expected snapshot update") + }; + let log = snapshot + .logs + .first() + .cloned() + .ok_or_else(|| anyhow::anyhow!("expected preview log"))?; + Ok((snapshot.flashblock, log)) +} + +fn preconfirmed_batch(flashblock: FlashblockRef, log: Log) -> ReactiveInputBatch { + let provider = flashblock.provider.clone(); + let flashblock = Arc::new(flashblock); + let context = ReactiveContext { + chain_id: Some(CHAIN_ID), + source: InputSource::Flashblocks, + chain_status: ChainStatus::Preconfirmed { + flashblock: flashblock.clone(), + }, + block: Some(flashblock.block_ref()), + transaction_index: log.transaction_index, + log_index: log.log_index, + }; + ReactiveInputBatch::new(vec![ + ReactiveInputRecord::new(ReactiveInput::Log(log), context).with_provider(provider), + ]) + .with_chain_id(CHAIN_ID) + .with_delivery_scope(DeliveryScope::Preconfirmed) +} + +fn canonical_batch(pool: Address, topic: B256, value: U256) -> ReactiveInputBatch { + let block = BlockRef { + number: BLOCK_NUMBER, + hash: B256::repeat_byte(BLOCK_NUMBER as u8), + parent_hash: Some(canonical_parent().hash), + timestamp: Some(1_700_000_000 + BLOCK_NUMBER), + }; + let log = canonical_log(pool, topic, value); + let context = ReactiveContext { + chain_id: Some(CHAIN_ID), + source: InputSource::Subscription, + chain_status: ChainStatus::Included { + block, + confirmations: 0, + }, + block: Some(block), + transaction_index: log.transaction_index, + log_index: log.log_index, + }; + ReactiveInputBatch::new(vec![ReactiveInputRecord::new( + ReactiveInput::Log(log), + context, + )]) + .with_chain_id(CHAIN_ID) +} + +#[tokio::test(flavor = "multi_thread")] +async fn raw_preview_invalidation_replacement_and_canonical_reconciliation_are_one_pipeline() +-> Result<()> { + let pool = Address::repeat_byte(0x42); + let topic = B256::repeat_byte(0x43); + let value = U256::from(777); + let (mut cache, cache_asserter) = setup_cache_with_asserter().await?; + install_mock_erc20(&mut cache, pool); + + let subscriber_asserter = Asserter::new(); + subscriber_asserter.push_success(&U256::from(CHAIN_ID)); + subscriber_asserter.push_success(&U256::from(2)); + subscriber_asserter.push_success(&Some(canonical_block())); + subscriber_asserter.push_success(&vec![canonical_log(pool, topic, value)]); + subscriber_asserter.push_success(&Some(canonical_block())); + let provider = ProviderBuilder::new().connect_mocked_client(subscriber_asserter.clone()); + let source = ProviderRef::new("raw-json", 1); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Polling, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + subscriber.replace_interest_owners_with_global_backfill( + vec![( + HandlerId::new("raw-json-pool"), + vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(pool).event_signature(topic), + local_matcher: None, + route_key: None, + })], + )], + SubscriberBackfill::range(BLOCK_NUMBER - 1, BLOCK_NUMBER), + )?; + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(PoolHandler { pool }))?; + runtime.adopt_canonical_baseline(canonical_parent())?; + let mut adapter = RawJsonFlashblocksAdapter::new(source); + + let first = adapter + .ingest_json(&raw_frame(pool, topic, value))? + .ok_or_else(|| anyhow::anyhow!("expected first raw snapshot"))?; + subscriber.ingest_flashblock_update(first)?; + let first_batch = subscriber + .next_batch() + .await? + .ok_or_else(|| anyhow::anyhow!("expected first preconfirmation batch"))?; + assert_eq!(first_batch.records().len(), 1); + assert_eq!( + first_batch.records()[0].context.source, + InputSource::Flashblocks + ); + runtime.ingest_batch(&mut cache, first_batch)?; + assert_eq!( + cache.cached_storage_value(pool, U256::from(POOL_SLOT)), + Some(value) + ); + assert!(runtime.active_preconfirmation().is_some()); + assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0); + assert!(cache_asserter.read_q().is_empty()); + + let reset = adapter + .reset(ProviderRef::new("raw-json", 2))? + .ok_or_else(|| anyhow::anyhow!("expected active-source invalidation"))?; + subscriber.ingest_flashblock_update(reset)?; + let invalidation = subscriber + .next_batch() + .await? + .ok_or_else(|| anyhow::anyhow!("expected invalidation batch"))?; + assert!(invalidation.records().is_empty()); + runtime.ingest_batch(&mut cache, invalidation)?; + assert_eq!( + cache.cached_storage_value(pool, U256::from(POOL_SLOT)), + Some(U256::ZERO) + ); + assert!(runtime.active_preconfirmation().is_none()); + + let replacement = adapter + .ingest_json(&raw_frame(pool, topic, value))? + .ok_or_else(|| anyhow::anyhow!("expected replacement raw snapshot"))?; + subscriber.ingest_flashblock_update(replacement)?; + let replacement_batch = subscriber + .next_batch() + .await? + .ok_or_else(|| anyhow::anyhow!("expected replacement preconfirmation batch"))?; + runtime.ingest_batch(&mut cache, replacement_batch)?; + assert_eq!( + runtime + .active_preconfirmation() + .map(|flashblock| flashblock.provider.generation), + Some(2) + ); + + let Some(canonical) = subscriber.next_batch().await? else { + bail!("expected canonical reconciliation batch"); + }; + assert_eq!(canonical.records().len(), 1); + assert_eq!(canonical.records()[0].context.source, InputSource::Backfill); + runtime.ingest_batch(&mut cache, canonical)?; + assert_eq!( + cache.cached_storage_value(pool, U256::from(POOL_SLOT)), + Some(value) + ); + assert!(runtime.active_preconfirmation().is_none()); + assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0); + assert!(cache_asserter.read_q().is_empty()); + assert!(subscriber_asserter.read_q().is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn replacing_interest_owners_immediately_restores_canonical_state() -> Result<()> { + let pool = Address::repeat_byte(0x42); + let topic = B256::repeat_byte(0x43); + let value = U256::from(777); + let (mut cache, cache_asserter) = setup_cache_with_asserter().await?; + install_mock_erc20(&mut cache, pool); + + let subscriber_asserter = Asserter::new(); + subscriber_asserter.push_success(&U256::from(CHAIN_ID)); + subscriber_asserter.push_success(&U256::from(2)); + subscriber_asserter.push_success(&Some(canonical_block())); + subscriber_asserter.push_success(&vec![canonical_log(pool, topic, value)]); + subscriber_asserter.push_success(&Some(canonical_block())); + let provider = ProviderBuilder::new().connect_mocked_client(subscriber_asserter.clone()); + let source = ProviderRef::new("raw-json", 1); + let interest = ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(pool).event_signature(topic), + local_matcher: None, + route_key: None, + }); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::Polling, + SubscriberConfig { + preconfirmations: PreconfirmationMode::Preferred, + ..SubscriberConfig::default() + }, + ); + subscriber + .configure_external_flashblock_updates(source.clone()) + .expect("configure external source"); + subscriber.replace_interest_owners_with_global_backfill( + vec![((HandlerId::new("raw-json-pool")), vec![interest.clone()])], + SubscriberBackfill::range(BLOCK_NUMBER - 1, BLOCK_NUMBER), + )?; + + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(PoolHandler { pool }))?; + runtime.adopt_canonical_baseline(canonical_parent())?; + let mut adapter = RawJsonFlashblocksAdapter::new(source); + let preview = adapter + .ingest_json(&raw_frame(pool, topic, value))? + .ok_or_else(|| anyhow::anyhow!("expected raw snapshot"))?; + subscriber.ingest_flashblock_update(preview)?; + let preview_batch = subscriber + .next_batch() + .await? + .ok_or_else(|| anyhow::anyhow!("expected preconfirmation batch"))?; + runtime.ingest_batch(&mut cache, preview_batch)?; + assert_eq!( + cache.cached_storage_value(pool, U256::from(POOL_SLOT)), + Some(value) + ); + assert!(runtime.active_preconfirmation().is_some()); + + subscriber.replace_interest_owners(vec![(HandlerId::new("raw-json-pool"), vec![interest])])?; + let invalidation = subscriber + .next_batch() + .await? + .ok_or_else(|| anyhow::anyhow!("owner replacement must revoke the preview"))?; + assert!(invalidation.records().is_empty()); + runtime.ingest_batch(&mut cache, invalidation)?; + + assert_eq!( + cache.cached_storage_value(pool, U256::from(POOL_SLOT)), + Some(U256::ZERO) + ); + assert!(runtime.active_preconfirmation().is_none()); + assert!(cache_asserter.read_q().is_empty()); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn speculative_cache_requires_the_exact_canonical_successor_lineage() -> Result<()> { + let pool = Address::repeat_byte(0x42); + let topic = B256::repeat_byte(0x43); + let value = U256::from(777); + let (preview, log) = standardized_preview(pool, topic, value)?; + + { + let (mut cache, _) = setup_cache_with_asserter().await?; + install_mock_erc20(&mut cache, pool); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(PoolHandler { pool }))?; + runtime.adopt_canonical_baseline(canonical_parent())?; + runtime.ingest_batch(&mut cache, preconfirmed_batch(preview.clone(), log.clone()))?; + assert!(runtime.active_preconfirmation().is_some()); + } + + { + let (mut cache, _) = setup_cache_with_asserter().await?; + install_mock_erc20(&mut cache, pool); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(PoolHandler { pool }))?; + runtime.adopt_canonical_baseline(canonical_parent())?; + runtime.ingest_batch(&mut cache, preconfirmed_batch(preview.clone(), log.clone()))?; + let mut wrong_parent = preview.clone(); + wrong_parent.parent_hash = Some(B256::repeat_byte(0x01)); + assert!( + runtime + .ingest_batch(&mut cache, preconfirmed_batch(wrong_parent, log.clone())) + .expect_err("wrong-parent preview must fail closed") + .to_string() + .contains("parent") + ); + assert!(runtime.active_preconfirmation().is_none()); + assert_eq!( + cache.cached_storage_value(pool, U256::from(POOL_SLOT)), + Some(U256::ZERO) + ); + } + + { + let (mut cache, _) = setup_cache_with_asserter().await?; + install_mock_erc20(&mut cache, pool); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(PoolHandler { pool }))?; + runtime.adopt_canonical_baseline(canonical_parent())?; + let mut missing_parent = preview.clone(); + missing_parent.parent_hash = None; + assert!( + runtime + .ingest_batch(&mut cache, preconfirmed_batch(missing_parent, log.clone())) + .expect_err("missing-parent preview must fail closed") + .to_string() + .contains("parent") + ); + assert!(runtime.active_preconfirmation().is_none()); + } + + { + let (mut cache, _) = setup_cache_with_asserter().await?; + install_mock_erc20(&mut cache, pool); + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + runtime.register_handler(Arc::new(PoolHandler { pool }))?; + runtime.adopt_canonical_baseline(canonical_parent())?; + let stale_preview = preconfirmed_batch(preview.clone(), log.clone()); + runtime.ingest_batch(&mut cache, stale_preview.clone())?; + runtime.ingest_batch(&mut cache, canonical_batch(pool, topic, value))?; + assert!(runtime.active_preconfirmation().is_none()); + assert!( + runtime + .ingest_batch(&mut cache, stale_preview) + .expect_err("a canonical race must reject the stale preview replay") + .to_string() + .contains("successor") + ); + assert!(runtime.active_preconfirmation().is_none()); + } + + Ok(()) +} diff --git a/tests/reactive_flashblocks.rs b/tests/reactive_flashblocks.rs index 4d8d505..a51ee17 100644 --- a/tests/reactive_flashblocks.rs +++ b/tests/reactive_flashblocks.rs @@ -39,6 +39,15 @@ fn flashblock(provider: ProviderRef, index: u64, hash: B256) -> FlashblockRef { } } +fn canonical_parent() -> BlockRef { + BlockRef { + number: 100, + hash: B256::repeat_byte(0x64), + parent_hash: Some(B256::repeat_byte(0x63)), + timestamp: Some(1_700_000_100), + } +} + fn rpc_log(address: Address, block: BlockRef, tx: u8) -> Log { Log { inner: PrimitiveLog::new_unchecked(address, Vec::new(), Bytes::new()), @@ -169,6 +178,10 @@ fn flashblocks_policy_is_disabled_by_default_and_canonical_certification_is_boun let default = SubscriberConfig::default(); assert_eq!(default.preconfirmations, PreconfirmationMode::Disabled); assert_eq!(default.canonical_head_poll_interval.as_millis(), 500); + assert_eq!( + default.canonical_head_request_timeout, + std::time::Duration::from_secs(3) + ); } #[tokio::test] @@ -193,6 +206,7 @@ async fn preconfirmed_updates_are_visible_then_discarded_before_canonical_ingest slot, value: speculative_value, }))?; + runtime.adopt_canonical_baseline(canonical_parent())?; let record = preconfirmed_record(address, flashblock.clone()); assert_eq!(record.provider.as_ref(), Some(&provider)); @@ -206,7 +220,7 @@ async fn preconfirmed_updates_are_visible_then_discarded_before_canonical_ingest Some(speculative_value) ); assert_eq!(runtime.active_preconfirmation(), Some(&flashblock)); - assert!(runtime.last_canonical_block().is_none()); + assert_eq!(runtime.last_canonical_block(), Some(canonical_parent())); runtime.ingest_batch( &mut cache, @@ -247,6 +261,7 @@ async fn preconfirmed_branch_installs_pending_rpc_pin_and_complete_block_environ slot, value: U256::from(99), }))?; + runtime.adopt_canonical_baseline(canonical_parent())?; let pending = flashblock( ProviderRef::new("base-flashblocks", 3), 2, @@ -298,6 +313,7 @@ async fn cumulative_previews_preserve_generation_local_fills_without_leaking_the slot, value: U256::from(99), }))?; + runtime.adopt_canonical_baseline(canonical_parent())?; let provider = ProviderRef::new("base-flashblocks", 3); runtime.ingest_batch( @@ -376,6 +392,7 @@ async fn conflicting_duplicate_index_revokes_the_speculative_branch() -> Result< slot, value: U256::from(99), }))?; + runtime.adopt_canonical_baseline(canonical_parent())?; let provider = ProviderRef::new("base-flashblocks", 3); runtime.ingest_batch( &mut cache, From 0c412bc70d87b4687c5abcd341bbcaef1f14e8cc Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Tue, 11 Aug 2026 13:49:08 +0100 Subject: [PATCH 7/8] Release evm-fork-cache 0.4.0-alpha.4 --- CHANGELOG.md | 46 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 58 ++- RELEASING.md | 19 +- SECURITY.md | 2 +- docs/KNOWN_ISSUES.md | 7 +- docs/raw-json-flashblocks-acceptance.md | 33 +- examples/reactive_alloy_amm_live_probe.rs | 5 + scripts/check-security-exceptions.sh | 2 +- src/cache/mod.rs | 2 + src/cache/overlay.rs | 126 ++++++ src/cache/snapshot.rs | 39 ++ src/cancellation.rs | 59 +++ src/errors.rs | 4 + src/lib.rs | 3 + src/reactive/mod.rs | 362 +++++++++++++-- src/reactive/raw_json_flashblocks.rs | 527 +++++++++++++++++++++- tests/raw_json_flashblocks.rs | 287 +++++++++++- tests/raw_json_flashblocks_runtime.rs | 30 +- tests/reactive_alloy_subscriber.rs | 19 +- tests/reactive_reorg.rs | 22 + tests/snapshot_overlay.rs | 321 ++++++++++++- 23 files changed, 1862 insertions(+), 115 deletions(-) create mode 100644 src/cancellation.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d4849..8d18282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,56 @@ surface freezes at 1.0. ## [Unreleased] +## [0.4.0-alpha.4] - 2026-08-11 + +### Added + +- Added the provider-free, cloneable `SimulationCancellationToken` scope and + `EvmOverlay::call_raw_with_access_list_with_cancellation`. The REVM inspector + exposes a started latch, observes cancellation at instruction boundaries, + reverts the call checkpoint, and returns typed `OverlayError::Cancelled`; + uncancelled calls retain the existing result and access-list semantics. One + scope may cover related multi-chain or access-list replay calls, but cannot be + reset or carried into a later independent candidate. An executing database + callback or precompile remains cooperative only at the next instruction + boundary. +- Added `EvmSnapshot::block_hash`, a provider-free read-only lookup that exposes + only block hashes resident when the immutable snapshot was created and never + infers a current block hash from EVM context alone. +- Added `EvmSnapshot::block_context_hash`, which preserves a `BlockId::Hash` + current-block identity separately from EVM `BLOCKHASH` semantics, and + `account_code_hash(address)` for provider-free address-bound runtime + attestation. Both values are immutable after the snapshot is issued. +- Added `BufferedRawJsonFlashblocksAdapter`, a provider-free wrapper that can + retain exactly one frame at exactly one missing index for a caller-selected + 300–500 millisecond window. The caller owns the monotonic clock and timer; + expiry emits a typed `IndexGap` invalidation and quarantines the late + remainder until a new payload begins. +- Added deterministic coverage for in-order drain, timeout, buffered conflicts, + second gaps, reset and payload replacement, malformed and resource-exhausting + frames, and the one-frame/one-index bound. +- Added provider-free `FlashblockIngressTiming` metadata and timed raw/subscriber + handoff APIs. A buffered future frame retains its original caller-clock + arrival when the missing index later drains, and native Base/OP adapters stamp + ingress before normalization or pending-state materialization. +- Exposed `FlashblockRef::same_base_identity` as a narrow provider-free lineage + predicate so consumers can avoid carrying a trace across pending-block base + replacement without treating a preview as canonical. + ### Changed +- Raw JSON Flashblocks applications may opt into bounded single-index reorder + tolerance without changing `RawJsonFlashblocksAdapter`'s immediate behavior. + The wrapper remains transport-free and has no canonical-state or execution + authority. - Canonical-head certification requests used by Flashblocks generations now fail closed after `SubscriberConfig::canonical_head_request_timeout` (three seconds by default). A silently wedged request can therefore surface through subscriber-driver failure and provider rotation instead of leaving canonical progress indefinitely stalled. +- Preconfirmation batches now carry the earliest source ingress among their + contributing records. The timing is optional for compatibility and affects + neither Flashblock identity nor canonical or execution authority. ## [0.4.0-alpha.3] - 2026-08-07 @@ -1202,7 +1245,8 @@ pre-release development phases (see [`docs/ROADMAP.md`](docs/ROADMAP.md)). - `EvmCache` requires a multi-thread tokio runtime for any RPC-touching path. - See [`docs/KNOWN_ISSUES.md`](docs/KNOWN_ISSUES.md) for current limitations. -[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.3...HEAD +[Unreleased]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.4...HEAD +[0.4.0-alpha.4]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.3...v0.4.0-alpha.4 [0.4.0-alpha.3]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.2...v0.4.0-alpha.3 [0.4.0-alpha.2]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.4.0-alpha.1...v0.4.0-alpha.2 [0.4.0-alpha.1]: https://github.com/KaiCode2/evm-fork-cache/compare/v0.3.0...v0.4.0-alpha.1 diff --git a/Cargo.lock b/Cargo.lock index eaf5072..3c804b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2061,7 +2061,7 @@ dependencies = [ [[package]] name = "evm-fork-cache" -version = "0.4.0-alpha.3" +version = "0.4.0-alpha.4" dependencies = [ "alloy-consensus", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index 3d941dd..3a3fdf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "evm-fork-cache" -version = "0.4.0-alpha.3" +version = "0.4.0-alpha.4" edition = "2024" rust-version = "1.90" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 7e2d721..e8f7cc2 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,19 @@ The reactive subscriber contract became asynchronous and explicitly durable in - Enable `raw-flashblocks-json` only when an application receives the supported receipt-enriched indexed JSON profile on a separate source socket. The crate converts application-data frames but never opens, reconnects, or rate-limits - that socket. + that socket. `BufferedRawJsonFlashblocksAdapter` optionally tolerates exactly + one missing index and one future frame for 300–500 milliseconds; the caller + still owns the monotonic timer and every lifecycle decision. +- Use `SimulationCancellationToken` with + `EvmOverlay::call_raw_with_access_list_with_cancellation` when a provider-free + blocking simulation scope must be superseded after it has entered REVM. One + scope may be cloned across related multi-chain or access-list replay calls; + it cannot be reset and must not be carried to a later independent candidate. + `has_started()` reports the first instruction boundary reached by any call in + the scope, and a cancelled call returns `OverlayError::Cancelled` after + reverting its overlay checkpoint. Cancellation is observed between EVM + instructions, not inside an executing database callback or precompile. The + existing overlay call APIs remain unchanged. ## What it provides today @@ -121,7 +133,11 @@ The reactive subscriber contract became asynchronous and explicitly durable in on-disk persistence for accounts, storage, bytecode, and immutable metadata. - **Snapshots and overlays** — `snapshot()` produces an immutable, `Send + Sync` point-in-time view; each `EvmOverlay` is a cheap clone that - simulates in isolation, ideal for parallel candidate evaluation. + simulates in isolation, ideal for parallel candidate evaluation. A caller may + bind a cloneable `SimulationCancellationToken` scope to related access-list + call paths to stop stale REVM work at an instruction boundary without + provider I/O or changing uncancelled results. Construct the overlay without + an external database for that wholly provider-free guarantee. - **Bundle simulation** — `simulate_bundle` applies an ordered sequence of transactions over cumulative block state (each transaction sees the previous one's writes), with an `Atomic` / `AllowReverts(indices)` revert policy and @@ -470,6 +486,34 @@ mode closed. Call `RawJsonFlashblocksAdapter::reset` and forward its returned invalidation whenever the source disconnects, is replaced, or returns an application-data or subscriber-admission error that cannot be proven irrelevant. +Sources that have demonstrated occasional one-index delivery reordering may +instead wrap the same normalization behavior with +`BufferedRawJsonFlashblocksAdapter`. `ingest_json_at(frame, now_millis)` returns +zero updates while it retains `expected + 1`, or the missing and retained +snapshots in order when `expected` arrives before the deadline. +`buffered_gap()` exposes `(missing_index, buffered_index, expires_at_millis)` so +the application can schedule its own timer; it must call `expire_gap_at` at or +after that deadline. Expiry emits `FlashblockInvalidationReason::IndexGap`, +discards the one retained frame, and ignores late remainder frames until a new +payload begins. Malformed data, conflicting duplicates, resource violations, +and a second gap still fail immediately. The timeout is construction-bounded to +300–500 milliseconds and the wrapper can retain only one parsed frame. + +This buffer is not a canonical-state cache and does not authorize trading or +other downstream triggers. Its outputs remain speculative standardized updates +subject to the subscriber's normal provenance, lineage, invalidation, and +canonical reconciliation checks. `RawJsonFlashblocksAdapter` itself retains its +existing immediate gap-invalidation semantics. + +Latency-sensitive callers may use `ingest_json_timed_at` and convert each +`TimedFlashblockUpdate::source_ingress_millis` into their process-local +`Instant` before `send_with_ingress`. A future frame retained across the single +allowed gap keeps the arrival supplied with that frame; it is not restamped +when the missing frame drains. `ReactiveInputBatch::preconfirmation_timing` +then exposes the earliest contributing source arrival. This optional metadata +is provider-free and observability-only: it never changes ordering, identity, +canonical state, or trigger authority. + For an externally managed source, `establish_flashblocks_preflight(expected_chain_id)` verifies the canonical subscriber's chain and stream topology but deliberately performs zero @@ -1039,12 +1083,12 @@ The much larger stress case exists to make the worst permitted parsing budget visible; the 16 MiB library default is not a recommended production setting. Applications should record source frame/count distributions, add explicit headroom, and set the four `RawJsonFlashblocksLimits` bounds accordingly. On the -same Apple M1 Pro in a 2026-08-07 release run, a 15,521,468-byte frame with -17,000 transactions and 34,000 logs measured 38.861 ms (38.356–39.504 ms 95% -confidence interval) and 380.90 MiB/s across 20 flat Criterion samples. A +same Apple M1 Pro in a 2026-08-10 release run, a 15,521,468-byte frame with +17,000 transactions and 34,000 logs measured 43.051 ms (40.628–46.041 ms +Criterion interval) and 343.83 MiB/s across 20 Criterion samples. A 4,108,968-byte application-limit frame with 4,500 transactions and 9,000 logs -measured 9.642 ms (9.291–10.209 ms) and 406.40 MiB/s; two of its 20 samples were -high severe outliers. +measured 10.522 ms (9.621–11.616 ms) and 372.42 MiB/s; three of its 20 samples +were high severe outliers and one was high mild. ```sh cargo bench # all offline benches diff --git a/RELEASING.md b/RELEASING.md index dd7ff1e..927c0d4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,11 +1,11 @@ # Releasing -`evm-fork-cache` 0.4.0-alpha.3 adds the default-off raw JSON Flashblocks -normalization layer to the existing Flashblocks compatibility set. Publish -`alloy-transport-balancer 0.3.0-alpha.2` first, then publish this crate before -any extension crate that declares `evm-fork-cache = "0.4.0-alpha.3"`, -including `evm-amm-state 0.3.0-alpha.4` and the remote/Hybrid subscriber -packages. +`evm-fork-cache` 0.4.0-alpha.4 adds bounded, caller-timed single-index reorder +tolerance to the default-off raw JSON Flashblocks normalization layer, immutable +snapshot lineage lookups, and provider-free cooperative cancellation scopes for +related overlay calls. Publish `alloy-transport-balancer 0.3.0-alpha.2` first, +then publish this crate before any extension crate that declares +`evm-fork-cache = "0.4.0-alpha.4"`. No release step is automatic: use clean, reviewed commits and never publish from a credential-bearing working tree. @@ -30,6 +30,7 @@ cargo test --locked --no-default-features --features raw-flashblocks-json --test cargo test --locked --no-default-features --features raw-flashblocks-json,reactive-polling --test raw_json_flashblocks_runtime cargo clippy --locked --example raw_json_flashblocks_subscriber_acceptance --features raw-flashblocks-json,reactive-ws --no-deps -- -D warnings cargo +1.90.0 check --locked --lib +cargo +1.90.0 check --locked --lib --no-default-features --features raw-flashblocks-json cargo bench --no-run --all-features --locked cargo bench --locked --bench raw_json_flashblocks --no-default-features --features raw-flashblocks-json -- raw_json_flashblocks_application_limit cargo bench --locked --bench raw_json_flashblocks --no-default-features --features raw-flashblocks-json -- raw_json_flashblocks_near_limit @@ -87,11 +88,11 @@ the core's retained canonical history exactly. ```bash cargo publish --locked -git tag -s v0.4.0-alpha.3 -m "Release evm-fork-cache v0.4.0-alpha.3" -git push origin v0.4.0-alpha.3 +git tag -s v0.4.0-alpha.4 -m "Release evm-fork-cache v0.4.0-alpha.4" +git push origin v0.4.0-alpha.4 ``` -Wait for 0.4.0-alpha.3 to appear in the crates.io index before removing sibling path +Wait for 0.4.0-alpha.4 to appear in the crates.io index before removing sibling path dependencies and verifying downstream extension packages. Publish only after explicit authorization; preparing or running this checklist is not permission to publish, tag, or push. diff --git a/SECURITY.md b/SECURITY.md index 259a3e2..ba47ace 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -106,7 +106,7 @@ The stable and MSRV jobs use the same reviewed toolchain-action commit and pass their requested toolchain explicitly. Updating any action requires verifying the new upstream ref and full commit before changing the pin. -Sibling development dependencies are also immutable in CI. The alpha.2 cache +Sibling development dependencies are also immutable in CI. The alpha.4 cache workflow checks out `alloy-transport-balancer` at exact commit `7868bea593dec5748ad7475d1909fc3a2de0d4ad`, matching the first candidate in the documented publish order. Changing that revision requires rerunning the diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index d9be61f..185cc9d 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -324,7 +324,7 @@ surface was moved out of this crate. `base`/`static` header, `diff.transactions`, and exact `metadata.receipts`. JSON-RPC envelopes, receipt-less previews, and binary SSZ require separate adapters. The core deliberately does not own the source - socket, authentication, liveness timeout, raw-frame receive queue, retry, + socket, authentication, liveness timeout, unbounded raw-frame receive queue, retry, backoff, rate limit, or provider rotation. The optional standardized-update handoff queue is bounded and exposes backpressure, but does not make any of those lifecycle decisions. A caller must forward `reset()`'s @@ -335,6 +335,11 @@ surface was moved out of this crate. subscriber verdict, while non-blocking sends return an acknowledgement receipt. A rejection requires caller-owned generation revocation/reconnect; local subscriber capacity rejection does not permanently quarantine the endpoint. + The optional `BufferedRawJsonFlashblocksAdapter` retains at most one parsed + future frame across exactly one missing index for a construction-bounded + 300–500 millisecond window. It starts no timer: callers must schedule from + `buffered_gap()` and invoke `expire_gap_at()`. It never promotes a preview to + canonical state and grants no downstream trigger authority. - **Speculative runtime state requires exact canonical lineage.** A pre-confirmed batch is accepted only after the runtime has adopted a canonical coverage head and only when the preview is its exact numbered child with the diff --git a/docs/raw-json-flashblocks-acceptance.md b/docs/raw-json-flashblocks-acceptance.md index d001622..14a9610 100644 --- a/docs/raw-json-flashblocks-acceptance.md +++ b/docs/raw-json-flashblocks-acceptance.md @@ -21,6 +21,23 @@ both channel closure and queued malformed updates. The live probe performs the zero-Flashblocks-RPC preflight and rejects a canonical chain-id mismatch before starting its observation window. +The optional buffered wrapper additionally covers exactly one future frame +across one missing index, ordered two-frame drain within a 300–500 millisecond +caller-timed window, deadline invalidation, late-remainder quarantine, +same-index conflict, a second future index, source reset, payload replacement, +malformed input, resource exhaustion, and a property check that no more distant +index enters the buffer. The immediate adapter remains unchanged. The wrapper +performs no provider request, starts no timer, mutates no canonical state, and +does not authorize execution from a provisional update. + +Timed coverage additionally proves that each member of an ordered two-frame +gap drain retains the monotonic arrival supplied on its original adapter call. +The standardized subscriber handoff preserves that timing through queued +normalization, record construction, scoped batching, and the public reactive +batch. Native Base coverage retains the earlier pending-log arrival when the +later cumulative preview releases that buffered log. Timing is optional +metadata only; absence cannot be interpreted as a later inferred ingress. + The public package check separately verifies that enabling only `raw-flashblocks-json` does not add a socket transport to the library dependency surface. The live example's WebSocket client is a development dependency and @@ -79,10 +96,10 @@ or rotate the speculative source when a limit is exceeded. Canonical processing must remain independent in preferred mode. A 4,108,968-byte stress frame just below that application byte limit, containing -4,500 transactions and 9,000 logs, measured 9.071 milliseconds (8.972–9.242 -milliseconds Criterion interval) and 431.98 MiB/s on the Apple M1 Pro release -build on 2026-08-07. One of 20 samples was classified as a high mild outlier and -one as a high severe outlier. +4,500 transactions and 9,000 logs, measured 10.522 milliseconds (9.621–11.616 +milliseconds Criterion interval) and 372.42 MiB/s on the Apple M1 Pro release +build on 2026-08-10. Of 20 samples, one was classified as a high mild outlier and +three as high severe outliers. The parsing stage precedes downstream decision timing and remains part of end-to-end signal latency; the count limits independently reject more allocation-heavy shapes that fit under the byte ceiling. @@ -106,9 +123,9 @@ canonical inclusion. The checked-in suite also builds a 15,521,468-byte frame containing 17,000 transactions and 34,000 logs, below every default count ceiling and close to the -16 MiB frame ceiling. On the same Apple M1 Pro in a release build on 2026-08-07, -its Criterion point estimate was 39.005 milliseconds (38.082–40.312 -milliseconds Criterion interval), or 379.50 MiB/s, with two high severe -outliers among 20 samples. Its purpose is to expose the bounded worst-case +16 MiB frame ceiling. On the same Apple M1 Pro in a release build on 2026-08-10, +its Criterion point estimate was 43.051 milliseconds (40.628–46.041 +milliseconds Criterion interval), or 343.83 MiB/s, with one high mild and one +high severe outlier among 20 samples. Its purpose is to expose the bounded worst-case parsing cost before release. Production consumers should select smaller limits unless their measured source requires the broader compatibility envelope. diff --git a/examples/reactive_alloy_amm_live_probe.rs b/examples/reactive_alloy_amm_live_probe.rs index 840d90a..fda51f7 100644 --- a/examples/reactive_alloy_amm_live_probe.rs +++ b/examples/reactive_alloy_amm_live_probe.rs @@ -17,6 +17,11 @@ //! `--no-default-features --features reactive,reactive-polling` and set //! `LIVE_AMM_TRANSPORT=polling` to exercise the HTTP `watch_logs` fallback. +#![cfg_attr( + not(any(feature = "reactive-ws", feature = "reactive-polling")), + allow(dead_code, unused_imports) +)] + use std::{ collections::BTreeMap, time::{Duration, Instant}, diff --git a/scripts/check-security-exceptions.sh b/scripts/check-security-exceptions.sh index 5e6839c..6391b60 100755 --- a/scripts/check-security-exceptions.sh +++ b/scripts/check-security-exceptions.sh @@ -159,7 +159,7 @@ bincode_graph="$({ } | sed -E 's# \(/[^)]*\)$##; s# \(\*\)$##')" expected_bincode_graph="$(printf '%s\n' \ '0bincode v1.3.3' \ - '1evm-fork-cache v0.4.0-alpha.3')" + '1evm-fork-cache v0.4.0-alpha.4')" if [[ "$bincode_graph" != "$expected_bincode_graph" ]]; then echo "The accepted bincode 1 compatibility scope changed." >&2 echo "Expected:" >&2 diff --git a/src/cache/mod.rs b/src/cache/mod.rs index 9f32f02..e2cf695 100644 --- a/src/cache/mod.rs +++ b/src/cache/mod.rs @@ -3684,6 +3684,7 @@ impl EvmCache { storage_cleared, accounts_not_existing, block_hashes, + block_context_hash: self.block.as_block_hash(), block_number: self.block_number, basefee: self.basefee, coinbase: self.coinbase, @@ -3983,6 +3984,7 @@ impl EvmCache { storage_cleared, accounts_not_existing, block_hashes, + block_context_hash: self.block.as_block_hash(), block_number: self.block_number, basefee: self.basefee, coinbase: self.coinbase, diff --git a/src/cache/overlay.rs b/src/cache/overlay.rs index f860f8d..69e169d 100644 --- a/src/cache/overlay.rs +++ b/src/cache/overlay.rs @@ -27,6 +27,7 @@ use super::snapshot::EvmSnapshot; use super::{CallSimulationResult, IERC20, SimStatus, TxConfig, unix_timestamp_secs_saturating}; use crate::access_set::StorageAccessList; use crate::bundle::{BundleOptions, BundleResult, BundleTx, RevertPolicy, TxOutcome}; +use crate::cancellation::SimulationCancellationToken; use crate::errors::{ OverlayError, OverlayResult as Result, SimError, SimHostError, SimulationError, SimulationResult, @@ -35,6 +36,23 @@ use crate::inspector::TransferInspector; use crate::mapping_probe::HashStorageProbe; use alloy_sol_types::SolCall; +#[derive(Clone, Debug)] +struct SimulationCancellationInspector { + token: SimulationCancellationToken, +} + +impl revm::Inspector for SimulationCancellationInspector +where + INTR: revm::interpreter::InterpreterTypes, +{ + fn step(&mut self, interpreter: &mut revm::interpreter::Interpreter, _context: &mut CTX) { + self.token.mark_started(); + if self.token.is_cancelled() { + interpreter.halt(revm::interpreter::InstructionResult::Stop); + } + } +} + type OverlayEvm<'a> = revm::MainnetEvm< Context, ()>, >; @@ -1114,6 +1132,113 @@ impl EvmOverlay { outcome } + /// Execute a non-committing call with cooperative cancellation. + /// + /// This has the same transaction and access-list semantics as + /// [`Self::call_raw_with_access_list_with`]. The supplied cancellation scope + /// may be shared by related overlay calls and is checked at every EVM + /// instruction boundary. If cancellation is observed, execution halts, the + /// journal checkpoint is reverted, and [`OverlayError::Cancelled`] is + /// returned. An uncancelled execution returns the same result and access-list + /// evidence as the existing API. + /// + /// Cancellation cannot interrupt a database callback or precompile that is + /// already executing; it is observed at the next EVM instruction boundary. + /// For a wholly provider-free cancellation path, construct this overlay with + /// no external database. A scope cannot be reset after cancellation and must + /// not be reused for a later independent candidate. + /// + /// # Errors + /// + /// Returns [`OverlayError::Cancelled`] when the token is cancelled, or the + /// same transaction-environment and host errors as + /// [`Self::call_raw_with_access_list_with`]. + pub fn call_raw_with_access_list_with_cancellation( + &mut self, + from: Address, + to: Address, + calldata: Bytes, + tx: &TxConfig, + cancellation: &SimulationCancellationToken, + ) -> Result<(ExecutionResult, StorageAccessList)> { + if cancellation.is_cancelled() { + return Err(OverlayError::Cancelled); + } + + let mut builder = TxEnv::builder() + .caller(from) + .kind(TxKind::Call(to)) + .data(calldata) + .value(tx.value); + if let Some(gas_limit) = tx.gas_limit { + builder = builder.gas_limit(gas_limit); + } + if let Some(gas_price) = tx.gas_price { + builder = builder.gas_price(gas_price); + } + if let Some(nonce) = tx.nonce { + builder = builder.nonce(nonce); + } + if let Some(access_list) = &tx.access_list { + builder = builder.access_list(access_list.clone()); + } + let tx_env = builder.build().map_err(OverlayError::tx_env)?; + + let buffer = Rc::new(RefCell::new(std::mem::take(&mut self.reusable_buffer))); + let local = LocalContext { + shared_memory_buffer: Rc::clone(&buffer), + precompile_error_message: None, + }; + let inspector = SimulationCancellationInspector { + token: cancellation.clone(), + }; + + let outcome = { + let mut evm = self.build_evm_with_inspector_local(inspector, local); + use revm::context_interface::JournalTr; + let checkpoint = evm.journaled_state.checkpoint(); + match evm.inspect_one_tx(tx_env) { + Ok(result) => { + let cancelled = cancellation.is_cancelled(); + let mut access_list = StorageAccessList::default(); + if !cancelled { + for (address, account) in evm.journaled_state.state.iter() { + if account.is_touched() { + access_list.accounts.insert(*address); + let code_hash = account.info.code_hash; + if code_hash != B256::ZERO + && code_hash != revm::primitives::KECCAK_EMPTY + { + access_list.code_hashes.insert(code_hash); + } + for slot_key in account.storage.keys() { + access_list.slots.insert((*address, *slot_key)); + } + } + } + } + evm.journaled_state.checkpoint_revert(checkpoint); + if cancelled { + Err(OverlayError::Cancelled) + } else { + Ok((result, access_list)) + } + } + Err(error) => { + evm.journaled_state.checkpoint_revert(checkpoint); + if cancellation.is_cancelled() { + Err(OverlayError::Cancelled) + } else { + Err(OverlayError::transact(error)) + } + } + } + }; + + self.reclaim_buffer(buffer); + outcome + } + /// Write a storage value into this overlay's dirty layer. /// /// The dirty layer takes precedence over the snapshot on subsequent reads @@ -1522,6 +1647,7 @@ mod tests { storage_cleared: HashSet::new(), accounts_not_existing: HashSet::new(), block_hashes, + block_context_hash: None, block_number: None, basefee: None, coinbase: None, diff --git a/src/cache/snapshot.rs b/src/cache/snapshot.rs index 8048d2a..f068f56 100644 --- a/src/cache/snapshot.rs +++ b/src/cache/snapshot.rs @@ -105,6 +105,13 @@ pub struct EvmSnapshot { /// excluded from `overlay_accounts` / `overlay_code_by_hash`. pub(crate) accounts_not_existing: HashSet

, pub(crate) block_hashes: HashMap, + /// Hash-pinned block identity captured from the cache's `BlockId`. + /// + /// This is deliberately separate from `block_hashes`: EVM `BLOCKHASH` + /// cannot return the current block's hash, while callers that attest an + /// immutable snapshot lineage still need to bind the snapshot to the + /// current canonical block. + pub(crate) block_context_hash: Option, // Block context pub(crate) block_number: Option, pub(crate) basefee: Option, @@ -133,6 +140,28 @@ impl EvmSnapshot { self.block_number } + /// Return the exact `BLOCKHASH` value resident for `number` when one was + /// captured by this immutable snapshot. + /// + /// This lookup is provider-free and never infers a hash from the snapshot's + /// EVM block context. In particular, [`block_number`](Self::block_number) + /// being `Some(number)` does not make that number's hash resident; callers + /// receive `None` unless the cache held an explicit block-hash entry when the + /// snapshot was created. + pub fn block_hash(&self, number: u64) -> Option { + self.block_hashes.get(&number).copied() + } + + /// Return the hash-pinned identity of the snapshot's current block context. + /// + /// This is `Some` only when the source cache was pinned with + /// `BlockId::Hash`; number/tag-pinned snapshots return `None`. It is not an + /// EVM `BLOCKHASH` value and is therefore kept separate from + /// [`block_hash`](Self::block_hash). + pub const fn block_context_hash(&self) -> Option { + self.block_context_hash + } + /// Base fee installed in the snapshot's EVM context. pub const fn basefee(&self) -> Option { self.basefee @@ -272,6 +301,15 @@ impl EvmSnapshot { .and_then(|s| s.get(&slot).copied()) } + /// Return the runtime-code hash resident for `address` in this snapshot. + /// + /// The lookup follows the same account-shadowing and known-absent rules as + /// EVM account reads. It is provider-free and is intended for callers that + /// bind offline evaluation to a reviewed deployed runtime identity. + pub fn account_code_hash(&self, address: Address) -> Option { + self.account_info(address).map(|info| info.code_hash) + } + /// Bytecode by `code_hash`: overlay (layer 1) wins, else the base (layer 2). pub(crate) fn code(&self, code_hash: B256) -> Option<&Bytecode> { self.overlay_code_by_hash @@ -310,6 +348,7 @@ mod tests { storage_cleared: HashSet::new(), accounts_not_existing: HashSet::new(), block_hashes: HashMap::new(), + block_context_hash: None, block_number: Some(100), basefee: Some(1000), coinbase: None, diff --git a/src/cancellation.rs b/src/cancellation.rs new file mode 100644 index 0000000..f6c3be6 --- /dev/null +++ b/src/cancellation.rs @@ -0,0 +1,59 @@ +//! Cooperative cancellation for a logical scope of EVM simulations. +//! +//! The signal itself is provider-free and is observed at EVM instruction +//! boundaries. It cannot interrupt a database callback or precompile that is +//! already executing; callers that require a wholly provider-free cancellation +//! path must construct their overlays without an external database. + +use std::sync::{ + Arc, + atomic::{AtomicU8, Ordering}, +}; + +const STARTED: u8 = 1; +const CANCELLED: u8 = 1 << 1; + +/// A cloneable cancellation signal for one logical simulation scope. +/// +/// Clones share one atomic state. The executing inspector marks the token as +/// started at its first instruction boundary, while an ingress owner may call +/// [`cancel`](Self::cancel) from another thread. One scope may contain several +/// related overlay calls, including multi-chain or access-list replay calls; +/// every clone and call observes the same cancellation decision. Cancellation +/// is monotonic: a token cannot be reset and must not be carried into a later, +/// independent simulation scope. +#[derive(Clone, Debug, Default)] +pub struct SimulationCancellationToken { + state: Arc, +} + +impl SimulationCancellationToken { + /// Create a fresh, unstarted and uncancelled simulation scope. + pub fn new() -> Self { + Self::default() + } + + /// Request cancellation. + /// + /// This operation is idempotent and safe before, during, or after EVM + /// execution. A running cancellable overlay observes it at an instruction + /// boundary. + pub fn cancel(&self) { + self.state.fetch_or(CANCELLED, Ordering::AcqRel); + } + + /// Whether cancellation has been requested. + pub fn is_cancelled(&self) -> bool { + self.state.load(Ordering::Acquire) & CANCELLED != 0 + } + + /// Whether any cancellable EVM in this scope reached its first instruction + /// boundary. + pub fn has_started(&self) -> bool { + self.state.load(Ordering::Acquire) & STARTED != 0 + } + + pub(crate) fn mark_started(&self) { + self.state.fetch_or(STARTED, Ordering::Release); + } +} diff --git a/src/errors.rs b/src/errors.rs index b2a41b4..7770cd5 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1015,6 +1015,10 @@ pub type CacheResult = Result; /// reverts as [`SimError`]. #[derive(Debug, thiserror::Error)] pub enum OverlayError { + /// The caller superseded this simulation scope while an EVM call was + /// executing. The overlay checkpoint has been reverted. + #[error("simulation was cooperatively cancelled")] + Cancelled, /// Transaction environment construction failed. #[error("failed to build transaction environment: {details}")] TxEnv { diff --git a/src/lib.rs b/src/lib.rs index f47c78a..c032b4f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -143,6 +143,7 @@ pub mod access_set; pub mod bulk_storage; pub mod bundle; pub mod cache; +pub mod cancellation; #[cfg(feature = "reactive")] pub mod cold_start; pub mod create3; @@ -159,6 +160,8 @@ pub mod reactive; pub mod state_update; pub mod tracing; +pub use cancellation::SimulationCancellationToken; + pub use access_list::{DEFAULT_CREATE_ACCESS_LIST_GAS_CAP, create_access_list_read_set}; pub use access_set::StorageAccessList; // Bulk storage extraction over eth_call state overrides — the default batch diff --git a/src/reactive/mod.rs b/src/reactive/mod.rs index 97949a0..00f5967 100644 --- a/src/reactive/mod.rs +++ b/src/reactive/mod.rs @@ -66,9 +66,10 @@ use crate::{ mod raw_json_flashblocks; #[cfg(feature = "raw-flashblocks-json")] pub use raw_json_flashblocks::{ - FlashblockInvalidation, FlashblockInvalidationReason, FlashblockSnapshot, FlashblockUpdate, - FlashblockUpdateAcknowledgement, FlashblockUpdateChannelError, FlashblockUpdateSender, - RawJsonFlashblocksAdapter, RawJsonFlashblocksError, RawJsonFlashblocksLimits, + BufferedRawJsonFlashblocksAdapter, FlashblockInvalidation, FlashblockInvalidationReason, + FlashblockSnapshot, FlashblockUpdate, FlashblockUpdateAcknowledgement, + FlashblockUpdateChannelError, FlashblockUpdateSender, RawJsonFlashblocksAdapter, + RawJsonFlashblocksError, RawJsonFlashblocksLimits, TimedFlashblockUpdate, }; /// Input accepted by the reactive runtime. @@ -140,6 +141,33 @@ impl ProviderRef { } } +/// Process-local monotonic time at which a Flashblock source item first +/// entered the typed subscriber boundary. +/// +/// This metadata never participates in Flashblock identity, ordering, +/// canonical state, or execution authority. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FlashblockIngressTiming { + source_ingress: Instant, +} + +impl FlashblockIngressTiming { + /// Bind a source item to its earliest process-local typed arrival. + pub const fn new(source_ingress: Instant) -> Self { + Self { source_ingress } + } + + /// Earliest process-local typed arrival for the source item. + pub const fn source_ingress(self) -> Instant { + self.source_ingress + } + + /// Retain the earliest contributing source arrival. + pub fn earliest(self, other: Self) -> Self { + Self::new(self.source_ingress.min(other.source_ingress)) + } +} + /// Identity of one cumulative pre-confirmed Flashblock snapshot. #[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct FlashblockRef { @@ -217,8 +245,15 @@ impl FlashblockRef { } } + /// Whether two cumulative previews bind the same pending-block base + /// fields, excluding payload index, cumulative transactions, and derived + /// content commitments. + /// + /// This provider-free predicate lets applications retain lineage metadata + /// only across snapshots that cannot have crossed a pending-block + /// replacement boundary. It grants no canonical or execution authority. #[cfg(feature = "raw-flashblocks-json")] - fn same_base_identity(&self, other: &Self) -> bool { + pub fn same_base_identity(&self, other: &Self) -> bool { self.block_number == other.block_number && self.parent_hash == other.parent_hash && self.timestamp == other.timestamp @@ -1759,6 +1794,8 @@ pub struct ReactiveInputBatchParts { pub payload_commitment: Option, /// Ordered chain controls sharing the delivery's commit boundary. pub chain_controls: Vec, + /// Original typed source ingress for a preconfirmed-only batch. + pub preconfirmation_timing: Option, } /// Batch of reactive input records. @@ -1774,6 +1811,7 @@ pub struct ReactiveInputBatch { delivery_scope: DeliveryScope, record_delivery_scopes: Option>, chain_controls: Vec, + preconfirmation_timing: Option, } type RuntimeInputDelivery = (ReactiveInputRecord, DeliveryAudience, DeliveryScope); @@ -1793,6 +1831,7 @@ impl ReactiveInputBatch { delivery_scope: DeliveryScope::Canonical, record_delivery_scopes: None, chain_controls: Vec::new(), + preconfirmation_timing: None, } } @@ -1872,6 +1911,7 @@ impl ReactiveInputBatch { delivery_scope: DeliveryScope::Canonical, record_delivery_scopes: None, chain_controls: Vec::new(), + preconfirmation_timing: None, } } @@ -1941,9 +1981,21 @@ impl ReactiveInputBatch { delivery_scope: DeliveryScope::Canonical, record_delivery_scopes: Some(scopes), chain_controls: Vec::new(), + preconfirmation_timing: None, } } + /// Attach original typed source ingress to a preconfirmed-only batch. + pub fn with_preconfirmation_timing(mut self, timing: FlashblockIngressTiming) -> Self { + self.preconfirmation_timing = Some(timing); + self + } + + /// Original typed source ingress for a preconfirmed-only batch. + pub const fn preconfirmation_timing(&self) -> Option { + self.preconfirmation_timing + } + /// Attach ordered chain-lifecycle controls to this delivery. /// /// A control-only batch must also call [`with_chain_id`](Self::with_chain_id). @@ -1982,6 +2034,7 @@ impl ReactiveInputBatch { let subscriber_checkpoint = self.subscriber_checkpoint; let payload_commitment = self.payload_commitment; let chain_controls = self.chain_controls; + let preconfirmation_timing = self.preconfirmation_timing; let audiences = self .record_audiences .unwrap_or_else(|| vec![self.audience; self.records.len()]); @@ -2002,6 +2055,7 @@ impl ReactiveInputBatch { subscriber_checkpoint, payload_commitment, chain_controls, + preconfirmation_timing, } } @@ -10493,6 +10547,7 @@ impl SubscriberInputScope { pub struct SubscriberInputRecord { record: ReactiveInputRecord, scope: SubscriberInputScope, + preconfirmation_timing: Option, } impl SubscriberInputRecord { @@ -10506,6 +10561,11 @@ impl SubscriberInputRecord { &self.scope } + /// Original typed source ingress when this is a preconfirmed record. + pub const fn preconfirmation_timing(&self) -> Option { + self.preconfirmation_timing + } + /// Consume the scoped value into its reactive input record. pub fn into_record(self) -> ReactiveInputRecord { self.record @@ -10527,6 +10587,7 @@ pub struct SubscriberInputBatch { chain_id: Option, chain_controls: Vec, preconfirmation_invalidated: bool, + preconfirmation_timing: Option, } /// Result of polling a scoped subscriber batch against one driver control @@ -10562,6 +10623,11 @@ impl SubscriberInputBatch { self.preconfirmation_invalidated } + /// Earliest typed source ingress contributing to a preconfirmed batch. + pub const fn preconfirmation_timing(&self) -> Option { + self.preconfirmation_timing + } + /// Consume the scoped subscriber delivery into a runtime-ready batch. /// /// Delivery audiences and the preconfirmed/canonical boundary are retained, @@ -10570,6 +10636,7 @@ impl SubscriberInputBatch { pub fn into_reactive_batch(self) -> ReactiveInputBatch { let chain_id = self.chain_id; let chain_controls = self.chain_controls; + let preconfirmation_timing = self.preconfirmation_timing; let mut batch = ReactiveInputBatch::from_scoped_records_with_delivery_scope( self.records.into_iter().map(|scoped| { let source = scoped.record.context.source; @@ -10617,6 +10684,9 @@ impl SubscriberInputBatch { if let Some(chain_id) = chain_id { batch = batch.with_chain_id(chain_id); } + if let Some(timing) = preconfirmation_timing { + batch = batch.with_preconfirmation_timing(timing); + } batch } } @@ -12472,7 +12542,7 @@ pub struct AlloySubscriber { recent_compat_owner_input_ref_sets: HashMap>, base_flashblock_header: Option<(FixedBytes<8>, BaseFlashblockBase)>, base_flashblock_transactions: Option<(FixedBytes<8>, u64, Vec, Vec)>, - unmatched_pending_logs: VecDeque<(usize, Log)>, + unmatched_pending_logs: VecDeque<(usize, Log, FlashblockIngressTiming)>, latest_preconfirmation: Option, preconfirmed_seen_logs: HashSet<(B256, u64)>, /// OP transaction receipts already proven for the active cumulative @@ -13942,6 +14012,12 @@ impl AlloySubscriber { None => record.scope != SubscriberInputScope::Preconfirmed, }) .count(); + let preconfirmation_timing = self + .pending_records + .iter() + .take(len) + .filter_map(SubscriberInputRecord::preconfirmation_timing) + .reduce(FlashblockIngressTiming::earliest); let records = self.pending_records.drain(..len).collect(); let chain_controls = if first_preconfirmation.is_none() && self.pending_records.is_empty() { self.pending_chain_controls.drain(..).collect() @@ -13955,6 +14031,7 @@ impl AlloySubscriber { preconfirmation_invalidated: std::mem::take( &mut self.pending_preconfirmation_invalidation, ), + preconfirmation_timing, }) } @@ -14181,7 +14258,7 @@ impl SubscriberStreams { self.entries.push(SubscriberStreamEntry { source, stream }); } - #[cfg(test)] + #[cfg(all(test, any(feature = "reactive-polling", feature = "reactive-ws")))] fn len(&self) -> usize { self.entries.len() } @@ -14376,12 +14453,23 @@ enum SubscriberEvent { source_id: usize, log: Log, }, + BasePendingLogTimed { + source_id: usize, + log: Log, + timing: FlashblockIngressTiming, + }, BaseFlashblock(BaseFlashblockWirePayload), + BaseFlashblockTimed { + payload: BaseFlashblockWirePayload, + timing: FlashblockIngressTiming, + }, OpFlashblockTick, + OpFlashblockTickTimed(FlashblockIngressTiming), CanonicalHeadTick, PreconfirmedLogs { flashblock: FlashblockRef, logs: Vec, + timing: FlashblockIngressTiming, }, FlashblockInvalidated, FlashblockObserved, @@ -14780,6 +14868,27 @@ where pub fn ingest_flashblock_update( &mut self, update: FlashblockUpdate, + ) -> Result<(), SubscriberError> { + self.ingest_flashblock_update_with_ingress( + update, + FlashblockIngressTiming::new(Instant::now()), + ) + } + + /// Ingest one standardized update with its original typed source arrival. + /// + /// This timing is observability-only and cannot mutate canonical state or + /// grant trigger authority. + /// + /// # Errors + /// + /// Returns the same validation and resource errors as + /// [`Self::ingest_flashblock_update`]. + #[cfg(feature = "raw-flashblocks-json")] + pub fn ingest_flashblock_update_with_ingress( + &mut self, + update: FlashblockUpdate, + timing: FlashblockIngressTiming, ) -> Result<(), SubscriberError> { validate_subscriber_config(&self.config)?; self.validate_flashblocks_setup()?; @@ -14835,7 +14944,11 @@ where provider.generation = provider.generation.max(flashblock.provider.generation); } if !logs.is_empty() { - self.enqueue_event(SubscriberEvent::PreconfirmedLogs { flashblock, logs }); + self.enqueue_event(SubscriberEvent::PreconfirmedLogs { + flashblock, + logs, + timing, + }); } } FlashblockUpdate::Invalidated(invalidation) => { @@ -15968,7 +16081,11 @@ where .await .map_err(provider_error)? .into_stream() - .map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log }); + .map(move |log| SubscriberEvent::BasePendingLogTimed { + source_id: id, + log, + timing: FlashblockIngressTiming::new(Instant::now()), + }); Ok(stream_with_termination(stream, source)) } @@ -15993,7 +16110,10 @@ where .await .map_err(provider_error)? .into_stream() - .map(SubscriberEvent::BaseFlashblock); + .map(|payload| SubscriberEvent::BaseFlashblockTimed { + payload, + timing: FlashblockIngressTiming::new(Instant::now()), + }); Ok(stream_with_termination( stream, SubscriberStreamSource::BaseFlashblocks, @@ -16032,7 +16152,12 @@ where interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let stream = stream::unfold(interval, |mut interval| async move { interval.tick().await; - Some((SubscriberEvent::OpFlashblockTick, interval)) + Some(( + SubscriberEvent::OpFlashblockTickTimed( + FlashblockIngressTiming::new(Instant::now()), + ), + interval, + )) }); Ok(stream_with_termination( stream, @@ -16310,11 +16435,28 @@ where &mut self, event: SubscriberEvent, ) -> Result>, SubscriberError> { + let event = match event { + SubscriberEvent::BasePendingLog { source_id, log } => { + SubscriberEvent::BasePendingLogTimed { + source_id, + log, + timing: FlashblockIngressTiming::new(Instant::now()), + } + } + SubscriberEvent::BaseFlashblock(payload) => SubscriberEvent::BaseFlashblockTimed { + payload, + timing: FlashblockIngressTiming::new(Instant::now()), + }, + SubscriberEvent::OpFlashblockTick => { + SubscriberEvent::OpFlashblockTickTimed(FlashblockIngressTiming::new(Instant::now())) + } + event => event, + }; match event { #[cfg(feature = "raw-flashblocks-json")] SubscriberEvent::ExternalFlashblockUpdate(queued) => { let provider = queued.update.provider().clone(); - match self.ingest_flashblock_update(queued.update) { + match self.ingest_flashblock_update_with_ingress(queued.update, queued.timing) { Ok(()) => { let _ = queued.acknowledgement.send(Ok(())); Ok(Some(SubscriberEvent::FlashblockObserved)) @@ -16359,7 +16501,11 @@ where } } } - SubscriberEvent::BasePendingLog { source_id, log } => { + SubscriberEvent::BasePendingLogTimed { + source_id, + log, + timing, + } => { let block_number = log.block_number.ok_or_else(|| { SubscriberError::Provider( "pendingLogs item is missing its pending block number".into(), @@ -16387,22 +16533,33 @@ where "unmatched pendingLogs exceeded max_pending_records".into(), )); } - self.unmatched_pending_logs.push_back((source_id, log)); + self.unmatched_pending_logs + .push_back((source_id, log, timing)); return Ok(None); }; let logs = self.filter_preconfirmed_logs(&flashblock, vec![log])?; Ok(Some(if logs.is_empty() { SubscriberEvent::FlashblockObserved } else { - SubscriberEvent::PreconfirmedLogs { flashblock, logs } + SubscriberEvent::PreconfirmedLogs { + flashblock, + logs, + timing, + } })) } - SubscriberEvent::BaseFlashblock(payload) => { + SubscriberEvent::BaseFlashblockTimed { + payload, + timing: source_timing, + } => { let (flashblock, recover_pending_snapshot) = self.accept_base_flashblock(payload)?; let mut logs = Vec::new(); let mut retained = VecDeque::new(); - while let Some((source_id, log)) = self.unmatched_pending_logs.pop_front() { + let mut timing = source_timing; + while let Some((source_id, log, log_timing)) = + self.unmatched_pending_logs.pop_front() + { let transaction_hash = log.transaction_hash; if log.block_number == Some(flashblock.block_number) && transaction_hash @@ -16410,12 +16567,13 @@ where .is_some_and(|hash| flashblock.contains_transaction(hash)) { let _ = source_id; + timing = timing.earliest(log_timing); logs.push(log); } else if log .block_number .is_some_and(|number| number >= flashblock.block_number) { - retained.push_back((source_id, log)); + retained.push_back((source_id, log, log_timing)); } else { // A late log for an older speculative block can no // longer be applied to the active cumulative branch. @@ -16440,7 +16598,7 @@ where }); if recover_pending_snapshot { if let Some(event) = self - .fetch_pending_flashblock(indexed_recovery) + .fetch_pending_flashblock_with_timing(indexed_recovery, timing) .await .map_err(PendingFlashblockPollError::into_subscriber)? { @@ -16453,20 +16611,37 @@ where Ok(Some(if logs.is_empty() { SubscriberEvent::FlashblockObserved } else { - SubscriberEvent::PreconfirmedLogs { flashblock, logs } + SubscriberEvent::PreconfirmedLogs { + flashblock, + logs, + timing, + } })) } - SubscriberEvent::OpFlashblockTick => self.poll_op_pending_flashblock().await, + SubscriberEvent::OpFlashblockTickTimed(timing) => { + self.poll_op_pending_flashblock(timing).await + } SubscriberEvent::CanonicalHeadTick => self.fetch_certified_canonical_head().await, - SubscriberEvent::PreconfirmedLogs { flashblock, logs } => { + SubscriberEvent::PreconfirmedLogs { + flashblock, + logs, + timing, + } => { let logs = self.filter_preconfirmed_logs(&flashblock, logs)?; Ok(Some(if logs.is_empty() { SubscriberEvent::FlashblockObserved } else { - SubscriberEvent::PreconfirmedLogs { flashblock, logs } + SubscriberEvent::PreconfirmedLogs { + flashblock, + logs, + timing, + } })) } SubscriberEvent::FlashblockObserved => Ok(None), + SubscriberEvent::BasePendingLog { .. } + | SubscriberEvent::BaseFlashblock(_) + | SubscriberEvent::OpFlashblockTick => unreachable!("normalized above"), event => Ok(Some(event)), } } @@ -16775,8 +16950,12 @@ where async fn poll_op_pending_flashblock( &mut self, + timing: FlashblockIngressTiming, ) -> Result>, SubscriberError> { - match self.fetch_pending_flashblock(None).await { + match self + .fetch_pending_flashblock_with_timing(None, timing) + .await + { Ok(event) => { self.consecutive_flashblock_poll_failures = 0; Ok(event) @@ -16805,9 +16984,22 @@ where } } + #[cfg(test)] async fn fetch_pending_flashblock( &mut self, indexed_recovery: Option<(FixedBytes<8>, u64, Vec)>, + ) -> Result>, PendingFlashblockPollError> { + self.fetch_pending_flashblock_with_timing( + indexed_recovery, + FlashblockIngressTiming::new(Instant::now()), + ) + .await + } + + async fn fetch_pending_flashblock_with_timing( + &mut self, + indexed_recovery: Option<(FixedBytes<8>, u64, Vec)>, + timing: FlashblockIngressTiming, ) -> Result>, PendingFlashblockPollError> { let samples_pending_range = self.chain_id.and_then(flashblocks_adapter) == Some(FlashblocksAdapter::PendingStatePolling); @@ -17003,7 +17195,11 @@ where return Ok(Some(if logs.is_empty() { SubscriberEvent::FlashblockObserved } else { - SubscriberEvent::PreconfirmedLogs { flashblock, logs } + SubscriberEvent::PreconfirmedLogs { + flashblock, + logs, + timing, + } })); } let logs = self @@ -17012,7 +17208,11 @@ where Ok(Some(if logs.is_empty() { SubscriberEvent::FlashblockObserved } else { - SubscriberEvent::PreconfirmedLogs { flashblock, logs } + SubscriberEvent::PreconfirmedLogs { + flashblock, + logs, + timing, + } })) } @@ -17334,8 +17534,11 @@ where | SubscriberEvent::PendingHash(_) | SubscriberEvent::PendingHashes(_) | SubscriberEvent::BasePendingLog { .. } - | SubscriberEvent::BaseFlashblock(_) + | SubscriberEvent::BasePendingLogTimed { .. } + | SubscriberEvent::BaseFlashblock { .. } + | SubscriberEvent::BaseFlashblockTimed { .. } | SubscriberEvent::OpFlashblockTick + | SubscriberEvent::OpFlashblockTickTimed(_) | SubscriberEvent::CanonicalHeadTick | SubscriberEvent::PreconfirmedLogs { .. } | SubscriberEvent::FlashblockInvalidated @@ -17432,8 +17635,11 @@ where | SubscriberEvent::PendingHash(_) | SubscriberEvent::PendingHashes(_) | SubscriberEvent::BasePendingLog { .. } - | SubscriberEvent::BaseFlashblock(_) + | SubscriberEvent::BasePendingLogTimed { .. } + | SubscriberEvent::BaseFlashblock { .. } + | SubscriberEvent::BaseFlashblockTimed { .. } | SubscriberEvent::OpFlashblockTick + | SubscriberEvent::OpFlashblockTickTimed(_) | SubscriberEvent::CanonicalHeadTick | SubscriberEvent::PreconfirmedLogs { .. } | SubscriberEvent::FlashblockInvalidated @@ -17562,13 +17768,18 @@ where ); } } - SubscriberEvent::PreconfirmedLogs { flashblock, logs } => { + SubscriberEvent::PreconfirmedLogs { + flashblock, + logs, + timing, + } => { for log in logs { let record = self .with_chain_id(preconfirmed_log_input_record::(log, flashblock.clone())); self.push_pending_record(SubscriberInputRecord { record, scope: SubscriberInputScope::Preconfirmed, + preconfirmation_timing: Some(timing), }); } } @@ -17576,8 +17787,11 @@ where self.pending_preconfirmation_invalidation = true; } SubscriberEvent::BasePendingLog { .. } - | SubscriberEvent::BaseFlashblock(_) + | SubscriberEvent::BasePendingLogTimed { .. } + | SubscriberEvent::BaseFlashblock { .. } + | SubscriberEvent::BaseFlashblockTimed { .. } | SubscriberEvent::OpFlashblockTick + | SubscriberEvent::OpFlashblockTickTimed(_) | SubscriberEvent::CanonicalHeadTick | SubscriberEvent::FlashblockObserved => {} #[cfg(feature = "raw-flashblocks-json")] @@ -17806,6 +18020,7 @@ where self.push_pending_record(SubscriberInputRecord { record: record.clone(), scope: SubscriberInputScope::OwnerOnly { owners }, + preconfirmation_timing: None, }); } if !newly_served.is_empty() { @@ -17817,6 +18032,7 @@ where scope: SubscriberInputScope::OwnerOnlyHandlers { owners: newly_served, }, + preconfirmation_timing: None, }); } return; @@ -17835,6 +18051,7 @@ where excluded: already_served, } }, + preconfirmation_timing: None, }); } @@ -17849,6 +18066,7 @@ where scope: SubscriberInputScope::OwnerOnlyHandlers { owners: vec![owner], }, + preconfirmation_timing: None, }); } @@ -17972,6 +18190,7 @@ where self.push_pending_record(SubscriberInputRecord { record, scope: SubscriberInputScope::OwnerOnly { owners }, + preconfirmation_timing: None, }); } @@ -18230,7 +18449,11 @@ where .await .map_err(provider_error)? .into_stream() - .map(move |log| SubscriberEvent::BasePendingLog { source_id: id, log }); + .map(move |log| SubscriberEvent::BasePendingLogTimed { + source_id: id, + log, + timing: FlashblockIngressTiming::new(Instant::now()), + }); Ok(stream_with_termination(stream, source)) } #[cfg(not(feature = "reactive-ws"))] @@ -18250,7 +18473,10 @@ where .await .map_err(provider_error)? .into_stream() - .map(SubscriberEvent::BaseFlashblock); + .map(|payload| SubscriberEvent::BaseFlashblockTimed { + payload, + timing: FlashblockIngressTiming::new(Instant::now()), + }); Ok(stream_with_termination( stream, SubscriberStreamSource::BaseFlashblocks, @@ -18270,7 +18496,12 @@ where interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let stream = stream::unfold(interval, |mut interval| async move { interval.tick().await; - Some((SubscriberEvent::OpFlashblockTick, interval)) + Some(( + SubscriberEvent::OpFlashblockTickTimed(FlashblockIngressTiming::new( + Instant::now(), + )), + interval, + )) }); Ok(stream_with_termination( stream, @@ -18391,6 +18622,13 @@ mod subscriber_helper_tests { }) } + fn base_flashblock_event(payload: BaseFlashblockWirePayload) -> SubscriberEvent { + SubscriberEvent::BaseFlashblockTimed { + payload, + timing: FlashblockIngressTiming::new(Instant::now()), + } + } + #[test] fn duplicate_flashblock_transaction_membership_is_rejected() { let transaction = format!("{:#x}", B256::repeat_byte(0x41)); @@ -18444,7 +18682,7 @@ mod subscriber_helper_tests { subscriber.chain_id = Some(8_453); subscriber - .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock( + .normalize_flashblock_event(base_flashblock_event(indexed_flashblock( transaction, B256::repeat_byte(0xa1), ))) @@ -18459,7 +18697,7 @@ mod subscriber_helper_tests { conflicting.diff.state_root = B256::repeat_byte(0xbb); assert!(matches!( subscriber - .normalize_flashblock_event(SubscriberEvent::BaseFlashblock( + .normalize_flashblock_event(base_flashblock_event( BaseFlashblockWirePayload::Indexed(conflicting), )) .await, @@ -18494,7 +18732,7 @@ mod subscriber_helper_tests { subscriber.chain_id = Some(8_453); subscriber - .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock( + .normalize_flashblock_event(base_flashblock_event(indexed_flashblock( transaction_a, B256::repeat_byte(0xa1), ))) @@ -18509,9 +18747,9 @@ mod subscriber_helper_tests { gap.base = None; gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 }); subscriber - .normalize_flashblock_event(SubscriberEvent::BaseFlashblock( - BaseFlashblockWirePayload::Indexed(gap), - )) + .normalize_flashblock_event(base_flashblock_event(BaseFlashblockWirePayload::Indexed( + gap, + ))) .await .expect("the missing index is recovered from pending state"); @@ -18551,7 +18789,7 @@ mod subscriber_helper_tests { subscriber.chain_id = Some(8_453); subscriber - .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(indexed_flashblock( + .normalize_flashblock_event(base_flashblock_event(indexed_flashblock( B256::repeat_byte(0x41), B256::repeat_byte(0xa1), ))) @@ -18566,9 +18804,9 @@ mod subscriber_helper_tests { gap.base = None; gap.metadata = Some(BaseFlashblockMetadata { block_number: 101 }); let event = subscriber - .normalize_flashblock_event(SubscriberEvent::BaseFlashblock( - BaseFlashblockWirePayload::Indexed(gap), - )) + .normalize_flashblock_event(base_flashblock_event(BaseFlashblockWirePayload::Indexed( + gap, + ))) .await .expect("preferred mode fails closed without pending recovery") .expect("generation invalidation is observable"); @@ -18629,7 +18867,7 @@ mod subscriber_helper_tests { ) .expect("decode first cumulative preview"); subscriber - .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(first)) + .normalize_flashblock_event(base_flashblock_event(first)) .await .expect("first preview is accepted"); @@ -18641,10 +18879,12 @@ mod subscriber_helper_tests { second_log.transaction_index = Some(0); second_log.log_index = Some(0); + let pending_log_ingress = Instant::now() - Duration::from_millis(25); let before_preview = subscriber - .normalize_flashblock_event(SubscriberEvent::BasePendingLog { + .normalize_flashblock_event(SubscriberEvent::BasePendingLogTimed { source_id: 0, log: second_log, + timing: FlashblockIngressTiming::new(pending_log_ingress), }) .await .expect("a zero-hash log for the next block must be buffered"); @@ -18663,13 +18903,19 @@ mod subscriber_helper_tests { ) .expect("decode second cumulative preview"); let event = subscriber - .normalize_flashblock_event(SubscriberEvent::BaseFlashblock(second)) + .normalize_flashblock_event(base_flashblock_event(second)) .await .expect("second preview is accepted") .expect("the matching buffered log is released"); - let SubscriberEvent::PreconfirmedLogs { flashblock, logs } = event else { + let SubscriberEvent::PreconfirmedLogs { + flashblock, + logs, + timing, + } = event + else { panic!("expected a preconfirmed log batch") }; + assert_eq!(timing.source_ingress(), pending_log_ingress); assert_eq!(flashblock.block_number, 102); assert_ne!(flashblock.content_hash, B256::ZERO); assert_eq!(flashblock.partial_block_hash, None); @@ -18707,7 +18953,7 @@ mod subscriber_helper_tests { } #[test] - #[cfg(feature = "raw-flashblocks-json")] + #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))] fn external_flashblocks_keep_normal_canonical_pubsub_sources_on_any_chain() { let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); let mut subscriber = AlloySubscriber::<_, Ethereum>::new( @@ -18781,7 +19027,7 @@ mod subscriber_helper_tests { } #[tokio::test] - #[cfg(feature = "raw-flashblocks-json")] + #[cfg(all(feature = "raw-flashblocks-json", feature = "reactive-ws"))] async fn external_flashblocks_configuration_is_rejected_after_registration_starts() { let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); let mut fresh = AlloySubscriber::<_, Ethereum>::new( @@ -20615,7 +20861,11 @@ mod subscriber_helper_tests { } } - #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] + #[cfg(any( + feature = "raw-flashblocks-json", + feature = "reactive-polling", + feature = "reactive-ws" + ))] fn rpc_block(number: u64, hash: B256) -> alloy_rpc_types_eth::Block { alloy_rpc_types_eth::Block::empty(alloy_rpc_types_eth::Header { hash, @@ -20918,6 +21168,7 @@ mod subscriber_helper_tests { } #[test] + #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] fn compatibility_owner_backfill_and_live_overlap_split_exact_audiences() { let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); let mut subscriber = AlloySubscriber::<_, Ethereum>::new( @@ -20982,6 +21233,7 @@ mod subscriber_helper_tests { } #[test] + #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] fn active_owner_replacement_commits_atomically_to_one_new_epoch() { let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); let mut subscriber = AlloySubscriber::<_, Ethereum>::new( @@ -21022,6 +21274,7 @@ mod subscriber_helper_tests { } #[test] + #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] fn compatibility_and_epoch_owner_lifecycles_cannot_mix() { let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); let mut subscriber = AlloySubscriber::<_, Ethereum>::new( @@ -21187,6 +21440,7 @@ mod subscriber_helper_tests { subscriber.push_pending_record(SubscriberInputRecord { record: log_input_record(rpc_log(false), InputSource::Poll), scope: SubscriberInputScope::Canonical { owners: Vec::new() }, + preconfirmation_timing: None, }); let error = subscriber @@ -21206,6 +21460,7 @@ mod subscriber_helper_tests { } #[test] + #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] fn lazy_backfill_queue_capacity_failure_is_atomic() { let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); let mut subscriber = AlloySubscriber::<_, Ethereum>::new( @@ -21249,6 +21504,7 @@ mod subscriber_helper_tests { } #[test] + #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] fn exact_owner_replacement_is_atomic_and_removes_crash_stale_owners() { let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); let mut subscriber = AlloySubscriber::<_, Ethereum>::new( @@ -21277,6 +21533,7 @@ mod subscriber_helper_tests { subscriber.push_pending_record(SubscriberInputRecord { record: log_input_record(rpc_log(false), InputSource::Poll), scope: SubscriberInputScope::Canonical { owners: Vec::new() }, + preconfirmation_timing: None, }); let baseline = BlockRef { number: 100, @@ -21375,6 +21632,7 @@ mod subscriber_helper_tests { } #[tokio::test(flavor = "multi_thread")] + #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] async fn exclusive_canonical_backfill_validates_the_retained_baseline_hash() { let asserter = Asserter::new(); asserter.push_success(&101u64); @@ -22869,7 +23127,11 @@ mod subscriber_helper_tests { } // A log interest matching `rpc_log` (address 0x42, topic0 0x01). - #[cfg(any(feature = "reactive-ws", feature = "reactive-polling"))] + #[cfg(any( + feature = "raw-flashblocks-json", + feature = "reactive-polling", + feature = "reactive-ws" + ))] fn log_interest_matching_rpc_log() -> ReactiveInterest { ReactiveInterest::Logs(LogInterest { provider_filter: Filter::new() diff --git a/src/reactive/raw_json_flashblocks.rs b/src/reactive/raw_json_flashblocks.rs index aeb74ea..8e17d4a 100644 --- a/src/reactive/raw_json_flashblocks.rs +++ b/src/reactive/raw_json_flashblocks.rs @@ -1,13 +1,16 @@ -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + time::Instant, +}; use alloy_primitives::{Address, B256, Bytes, FixedBytes, Keccak256, Log as PrimitiveLog}; use alloy_rpc_types_eth::Log; use tokio::sync::{mpsc, oneshot}; use super::{ - BaseFlashblockBase, FlashblockContentCommitment, FlashblockRef, ProviderRef, - deserialize_optional_rpc_u64, flashblock_content_hash, flashblock_transaction_hashes, - non_placeholder_hash, + BaseFlashblockBase, FlashblockContentCommitment, FlashblockIngressTiming, FlashblockRef, + ProviderRef, deserialize_optional_rpc_u64, flashblock_content_hash, + flashblock_transaction_hashes, non_placeholder_hash, }; /// Resource bounds applied while converting receipt-enriched JSON Flashblocks. @@ -108,6 +111,41 @@ pub enum FlashblockUpdate { Invalidated(FlashblockInvalidation), } +/// One normalized raw update paired with its caller-clock arrival. +/// +/// The millisecond value belongs to the monotonic clock supplied to +/// [`BufferedRawJsonFlashblocksAdapter::ingest_json_timed_at`]. Applications +/// convert it back to their `Instant` domain before subscriber handoff. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TimedFlashblockUpdate { + update: FlashblockUpdate, + source_ingress_millis: u64, +} + +impl TimedFlashblockUpdate { + const fn new(update: FlashblockUpdate, source_ingress_millis: u64) -> Self { + Self { + update, + source_ingress_millis, + } + } + + /// Borrow the normalized update. + pub const fn update(&self) -> &FlashblockUpdate { + &self.update + } + + /// Arrival in the caller-owned monotonic millisecond domain. + pub const fn source_ingress_millis(&self) -> u64 { + self.source_ingress_millis + } + + /// Consume the timed value into its normalized update. + pub fn into_update(self) -> FlashblockUpdate { + self.update + } +} + impl FlashblockUpdate { /// Provider generation carried by this standardized update. pub const fn provider(&self) -> &ProviderRef { @@ -165,15 +203,20 @@ impl FlashblockUpdateAcknowledgement { pub(crate) struct QueuedFlashblockUpdate { pub(crate) update: FlashblockUpdate, + pub(crate) timing: FlashblockIngressTiming, pub(crate) acknowledgement: oneshot::Sender>, } impl QueuedFlashblockUpdate { - fn new(update: FlashblockUpdate) -> (Self, FlashblockUpdateAcknowledgement) { + fn new( + update: FlashblockUpdate, + timing: FlashblockIngressTiming, + ) -> (Self, FlashblockUpdateAcknowledgement) { let (acknowledgement, receiver) = oneshot::channel(); ( Self { update, + timing, acknowledgement, }, FlashblockUpdateAcknowledgement { receiver }, @@ -219,8 +262,23 @@ impl FlashblockUpdateSender { /// the update but rejected its integrity or local resource requirements; /// revoke and replace that source generation before continuing. pub async fn send(&self, update: FlashblockUpdate) -> Result<(), FlashblockUpdateChannelError> { + self.send_with_ingress(update, FlashblockIngressTiming::new(Instant::now())) + .await + } + + /// Enqueue an update with its original process-local typed source arrival. + /// + /// # Errors + /// + /// Returns the same endpoint, closure, and subscriber-rejection errors as + /// [`Self::send`]. + pub async fn send_with_ingress( + &self, + update: FlashblockUpdate, + timing: FlashblockIngressTiming, + ) -> Result<(), FlashblockUpdateChannelError> { self.validate_endpoint(&update)?; - let (queued, acknowledgement) = QueuedFlashblockUpdate::new(update); + let (queued, acknowledgement) = QueuedFlashblockUpdate::new(update, timing); self.sender .send(queued) .await @@ -242,9 +300,23 @@ impl FlashblockUpdateSender { pub fn try_send( &self, update: FlashblockUpdate, + ) -> Result { + self.try_send_with_ingress(update, FlashblockIngressTiming::new(Instant::now())) + } + + /// Non-blockingly enqueue an update with its original typed source arrival. + /// + /// # Errors + /// + /// Returns the same endpoint, capacity, and closure errors as + /// [`Self::try_send`]. + pub fn try_send_with_ingress( + &self, + update: FlashblockUpdate, + timing: FlashblockIngressTiming, ) -> Result { self.validate_endpoint(&update)?; - let (queued, acknowledgement) = QueuedFlashblockUpdate::new(update); + let (queued, acknowledgement) = QueuedFlashblockUpdate::new(update, timing); self.sender.try_send(queued).map_err(|error| match error { mpsc::error::TrySendError::Full(_) => FlashblockUpdateChannelError::Full, mpsc::error::TrySendError::Closed(_) => FlashblockUpdateChannelError::Closed, @@ -308,7 +380,7 @@ pub enum RawJsonFlashblocksError { /// authentication, timeouts, retry, backoff, and provider rotation. On source /// replacement or disconnect, call [`Self::reset`] and forward the returned /// invalidation before accepting updates from the new provider generation. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct RawJsonFlashblocksAdapter { provider: ProviderRef, limits: RawJsonFlashblocksLimits, @@ -399,19 +471,22 @@ impl RawJsonFlashblocksAdapter { &mut self, frame: &[u8], ) -> Result, RawJsonFlashblocksError> { + let payload = self.decode_json(frame)?; + self.ingest(payload) + } + + fn decode_json(&self, frame: &[u8]) -> Result { if frame.len() > self.limits.max_frame_bytes { return Err(RawJsonFlashblocksError::FrameTooLarge); } let payload: RawFlashblockPayload = serde_json::from_slice(frame) .map_err(|error| RawJsonFlashblocksError::InvalidPayload(error.to_string()))?; - self.ingest(payload) + self.validate_index(payload.index)?; + Ok(payload) } - fn ingest( - &mut self, - payload: RawFlashblockPayload, - ) -> Result, RawJsonFlashblocksError> { - if usize::try_from(payload.index) + fn validate_index(&self, index: u64) -> Result<(), RawJsonFlashblocksError> { + if usize::try_from(index) .ok() .is_none_or(|index| index >= self.limits.max_flashblocks_per_payload) { @@ -419,6 +494,14 @@ impl RawJsonFlashblocksAdapter { "Flashblock index", )); } + Ok(()) + } + + fn ingest( + &mut self, + payload: RawFlashblockPayload, + ) -> Result, RawJsonFlashblocksError> { + self.validate_index(payload.index)?; if self.ignored_payload == Some(payload.payload_id) { return Ok(None); } @@ -674,6 +757,420 @@ impl RawJsonFlashblocksAdapter { reason, }) } + + fn invalidate_active( + &mut self, + payload_id: FixedBytes<8>, + reason: FlashblockInvalidationReason, + ) -> FlashblockUpdate { + self.active = None; + self.ignored_payload = Some(payload_id); + self.invalidation(payload_id, reason) + } +} + +const MIN_BUFFERED_GAP_MILLIS: u64 = 300; +const MAX_BUFFERED_GAP_MILLIS: u64 = 500; + +/// Provider-free adapter that tolerates one briefly reordered JSON Flashblock. +/// +/// This wrapper preserves [`RawJsonFlashblocksAdapter`]'s immediate behavior +/// except for one narrow case: when an active payload receives exactly +/// `expected_index + 1`, it retains that one parsed frame until the missing +/// index arrives or the caller-owned deadline expires. It performs no I/O, +/// starts no timer, mutates no canonical state, and grants no execution or +/// trigger authority. Applications must schedule their own timer from +/// [`Self::buffered_gap`] and call [`Self::expire_gap_at`]. +#[derive(Clone, Debug)] +pub struct BufferedRawJsonFlashblocksAdapter { + inner: RawJsonFlashblocksAdapter, + gap_timeout_millis: u64, + buffered: Option, +} + +#[derive(Clone, Debug)] +struct BufferedRawFlashblock { + payload: RawFlashblockPayload, + commitment: B256, + expected_index: u64, + source_ingress_millis: u64, + expires_at_millis: u64, +} + +impl BufferedRawJsonFlashblocksAdapter { + /// Construct a bounded one-frame reorder adapter. + /// + /// `gap_timeout_millis` must be in the reviewed inclusive range + /// `300..=500`. The caller supplies timestamps from one monotonic clock. + /// + /// # Errors + /// + /// Returns [`RawJsonFlashblocksError::InvalidLimits`] for an unreviewed gap + /// timeout or invalid raw-frame resource limits. + pub fn new( + provider: ProviderRef, + limits: RawJsonFlashblocksLimits, + gap_timeout_millis: u64, + ) -> Result { + if !(MIN_BUFFERED_GAP_MILLIS..=MAX_BUFFERED_GAP_MILLIS).contains(&gap_timeout_millis) { + return Err(RawJsonFlashblocksError::InvalidLimits( + "buffered gap timeout must be between 300 and 500 milliseconds", + )); + } + Ok(Self { + inner: RawJsonFlashblocksAdapter::with_limits(provider, limits)?, + gap_timeout_millis, + buffered: None, + }) + } + + /// Provider generation attached to normalized snapshots and invalidations. + pub const fn provider(&self) -> &ProviderRef { + self.inner.provider() + } + + /// Resource limits applied to immediate and buffered frames. + pub const fn limits(&self) -> RawJsonFlashblocksLimits { + self.inner.limits() + } + + /// Return `(missing_index, buffered_index, expires_at_millis)` when a + /// caller-owned gap timer is required. + pub fn buffered_gap(&self) -> Option<(u64, u64, u64)> { + self.buffered.as_ref().map(|buffered| { + ( + buffered.expected_index, + buffered.payload.index, + buffered.expires_at_millis, + ) + }) + } + + /// Decode one application-data frame at a caller-supplied monotonic time. + /// + /// The returned vector has at most two entries. Two snapshots are returned + /// only when the missing index and the retained next index are validated + /// atomically and drained in order. Errors preserve the last successfully + /// published state and any pending gap for explicit caller reset. + /// + /// # Errors + /// + /// Returns [`RawJsonFlashblocksError`] immediately for malformed, + /// unsupported, or resource-exhausting input. + pub fn ingest_json_at( + &mut self, + frame: &[u8], + now_millis: u64, + ) -> Result, RawJsonFlashblocksError> { + self.ingest_json_timed_at(frame, now_millis).map(|updates| { + updates + .into_iter() + .map(TimedFlashblockUpdate::into_update) + .collect() + }) + } + + /// Decode one application-data frame while retaining the original + /// caller-clock arrival for every emitted update. + /// + /// When a future frame is buffered across a one-index gap, its eventual + /// output retains the timestamp from the call that first supplied that + /// frame, not the later gap-closing call. This method is provider-free and + /// starts no timer. + /// + /// # Errors + /// + /// Returns [`RawJsonFlashblocksError`] under the same conditions as + /// [`Self::ingest_json_at`]. + pub fn ingest_json_timed_at( + &mut self, + frame: &[u8], + now_millis: u64, + ) -> Result, RawJsonFlashblocksError> { + let payload = self.inner.decode_json(frame)?; + let mut updates = Vec::with_capacity(2); + + if self + .buffered + .as_ref() + .is_some_and(|buffered| now_millis >= buffered.expires_at_millis) + { + let expired = self.buffered.as_ref().expect("checked above"); + if payload.payload_id == expired.payload.payload_id { + let payload_id = expired.payload.payload_id; + self.buffered = None; + updates.push(TimedFlashblockUpdate::new( + self.inner + .invalidate_active(payload_id, FlashblockInvalidationReason::IndexGap), + now_millis, + )); + return Ok(updates); + } + + // Validate the replacement on a clone before publishing the expiry. + // An application-data error therefore preserves the observable + // invalidation and the pending timer for an explicit retry/reset. + let payload_id = expired.payload.payload_id; + let invalidation = self + .inner + .invalidation(payload_id, FlashblockInvalidationReason::IndexGap); + let mut staged = self.inner.clone(); + let replacement = staged.ingest(payload)?; + self.inner = staged; + self.buffered = None; + updates.push(TimedFlashblockUpdate::new(invalidation, now_millis)); + if let Some(update) = replacement { + updates.push(TimedFlashblockUpdate::new(update, now_millis)); + } + return Ok(updates); + } + + if let Some(buffered) = self.buffered.as_ref() { + if payload.payload_id == buffered.payload.payload_id { + if payload.index == buffered.expected_index { + let buffered = self.buffered.as_ref().expect("checked above").clone(); + let mut staged = self.inner.clone(); + if let Some(update) = staged.ingest(payload)? { + updates.push(TimedFlashblockUpdate::new(update, now_millis)); + } + if let Some(update) = staged.ingest(buffered.payload)? { + updates.push(TimedFlashblockUpdate::new( + update, + buffered.source_ingress_millis, + )); + } + self.inner = staged; + self.buffered = None; + return Ok(updates); + } + + if payload.index == buffered.payload.index { + let commitment = self.validate_bufferable_payload(&payload)?; + if commitment == buffered.commitment { + return Ok(updates); + } + let payload_id = payload.payload_id; + self.buffered = None; + updates.push(TimedFlashblockUpdate::new( + self.inner.invalidate_active( + payload_id, + FlashblockInvalidationReason::ConflictingDuplicate, + ), + now_millis, + )); + return Ok(updates); + } + + if payload.index > buffered.payload.index { + self.validate_bufferable_payload(&payload)?; + let payload_id = payload.payload_id; + self.buffered = None; + updates.push(TimedFlashblockUpdate::new( + self.inner + .invalidate_active(payload_id, FlashblockInvalidationReason::IndexGap), + now_millis, + )); + return Ok(updates); + } + } else { + // A rejected replacement must preserve the unresolved buffer. + let mut staged = self.inner.clone(); + let replacement = staged.ingest(payload)?; + self.inner = staged; + self.buffered = None; + if let Some(update) = replacement { + updates.push(TimedFlashblockUpdate::new(update, now_millis)); + } + return Ok(updates); + } + } + + if self.can_buffer_one_gap(&payload) { + let commitment = self.validate_bufferable_payload(&payload)?; + let active = self + .inner + .active + .as_ref() + .expect("buffering requires active state"); + let expected_index = active.last_index.map_or(0, |index| index.saturating_add(1)); + self.buffered = Some(BufferedRawFlashblock { + payload, + commitment, + expected_index, + source_ingress_millis: now_millis, + expires_at_millis: now_millis.saturating_add(self.gap_timeout_millis), + }); + return Ok(updates); + } + + if self.is_more_than_one_index_ahead(&payload) { + self.validate_bufferable_payload(&payload)?; + } + + if let Some(update) = self.inner.ingest(payload)? { + if matches!(update, FlashblockUpdate::Invalidated(_)) { + self.buffered = None; + } + updates.push(TimedFlashblockUpdate::new(update, now_millis)); + } + Ok(updates) + } + + /// Expire a buffered gap using the same caller-owned monotonic clock. + /// + /// At or after the deadline this emits one typed `IndexGap` invalidation, + /// clears the retained frame, and ignores the late remainder of that + /// payload until a new payload begins. + pub fn expire_gap_at(&mut self, now_millis: u64) -> Option { + let expired = self + .buffered + .as_ref() + .is_some_and(|buffered| now_millis >= buffered.expires_at_millis); + if !expired { + return None; + } + let buffered = self.buffered.take().expect("checked above"); + Some(self.inner.invalidate_active( + buffered.payload.payload_id, + FlashblockInvalidationReason::IndexGap, + )) + } + + /// Revoke the active payload and clear any retained reorder frame. + /// + /// A rejected source transition preserves both the active state and buffer. + /// + /// # Errors + /// + /// Returns [`RawJsonFlashblocksError::InvalidSourceTransition`] under the + /// same conditions as [`RawJsonFlashblocksAdapter::reset`]. + pub fn reset( + &mut self, + provider: ProviderRef, + ) -> Result, RawJsonFlashblocksError> { + let invalidation = self.inner.reset(provider)?; + self.buffered = None; + Ok(invalidation) + } + + fn can_buffer_one_gap(&self, payload: &RawFlashblockPayload) -> bool { + let Some(active) = self.inner.active.as_ref() else { + return false; + }; + if active.payload_id != payload.payload_id { + return false; + } + let expected = active.last_index.map_or(0, |index| index.saturating_add(1)); + payload.index == expected.saturating_add(1) + } + + fn is_more_than_one_index_ahead(&self, payload: &RawFlashblockPayload) -> bool { + let Some(active) = self.inner.active.as_ref() else { + return false; + }; + if active.payload_id != payload.payload_id { + return false; + } + let expected = active.last_index.map_or(0, |index| index.saturating_add(1)); + payload.index > expected.saturating_add(1) + } + + fn validate_bufferable_payload( + &self, + payload: &RawFlashblockPayload, + ) -> Result { + let active = self + .inner + .active + .as_ref() + .expect("buffer validation requires active state"); + if payload + .metadata + .block_number + .is_some_and(|number| number != active.base.block_number) + || payload + .base + .as_ref() + .is_some_and(|base| base != &active.base) + { + return Err(RawJsonFlashblocksError::InvalidPayload( + "base header or metadata block numbers disagree with the active payload".into(), + )); + } + let transaction_hashes = flashblock_transaction_hashes(&payload.diff.transactions) + .map_err(|error| RawJsonFlashblocksError::InvalidPayload(error.to_string()))?; + let unique_transactions = transaction_hashes.iter().copied().collect::>(); + if unique_transactions.len() != transaction_hashes.len() { + return Err(RawJsonFlashblocksError::InvalidPayload( + "the transaction delta contains a duplicate hash".into(), + )); + } + let receipt_hashes = payload + .metadata + .receipts + .keys() + .copied() + .collect::>(); + if receipt_hashes != unique_transactions { + return Err(RawJsonFlashblocksError::InvalidPayload( + "receipt-map membership disagrees with the transaction delta".into(), + )); + } + if active + .cumulative_transactions + .len() + .saturating_add(transaction_hashes.len()) + > self.inner.limits.max_transactions_per_payload + { + return Err(RawJsonFlashblocksError::ResourceExhausted( + "transaction count", + )); + } + if transaction_hashes + .iter() + .any(|hash| active.transaction_set.contains(hash)) + { + return Err(RawJsonFlashblocksError::InvalidPayload( + "a transaction appeared in more than one indexed delta".into(), + )); + } + let log_count = + payload + .metadata + .receipts + .values() + .try_fold(0_usize, |count, receipt| { + for log in &receipt.logs { + if log.topics.len() > 4 { + return Err(RawJsonFlashblocksError::InvalidPayload( + "receipt log contains more than four topics".into(), + )); + } + } + count + .checked_add(receipt.logs.len()) + .ok_or(RawJsonFlashblocksError::ResourceExhausted("log count")) + })?; + if active.cumulative_logs.saturating_add(log_count) > self.inner.limits.max_logs_per_payload + { + return Err(RawJsonFlashblocksError::ResourceExhausted("log count")); + } + u64::try_from( + active + .cumulative_transactions + .len() + .saturating_add(transaction_hashes.len()), + ) + .map_err(|_| RawJsonFlashblocksError::ResourceExhausted("transaction index"))?; + active + .next_log_index + .checked_add( + u64::try_from(log_count) + .map_err(|_| RawJsonFlashblocksError::ResourceExhausted("log index"))?, + ) + .ok_or(RawJsonFlashblocksError::ResourceExhausted("log index"))?; + Ok(raw_payload_commitment(payload, &transaction_hashes)) + } } fn raw_payload_commitment(payload: &RawFlashblockPayload, transaction_hashes: &[B256]) -> B256 { @@ -750,7 +1247,7 @@ fn commit_optional_raw_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) { } } -#[derive(Debug)] +#[derive(Clone, Debug)] struct RawPayloadState { payload_id: FixedBytes<8>, base: BaseFlashblockBase, diff --git a/tests/raw_json_flashblocks.rs b/tests/raw_json_flashblocks.rs index 78fe7c8..3c4ea59 100644 --- a/tests/raw_json_flashblocks.rs +++ b/tests/raw_json_flashblocks.rs @@ -9,9 +9,9 @@ use alloy_provider::ProviderBuilder; use alloy_rpc_types_eth::Filter; use alloy_transport::mock::Asserter; use evm_fork_cache::reactive::{ - AlloySubscriber, FlashblockInvalidationReason, FlashblockUpdate, FlashblockUpdateChannelError, - PreconfirmationMode, ProviderRef, RawJsonFlashblocksAdapter, RawJsonFlashblocksLimits, - SubscriberConfig, SubscriberMode, + AlloySubscriber, BufferedRawJsonFlashblocksAdapter, FlashblockInvalidationReason, + FlashblockUpdate, FlashblockUpdateChannelError, PreconfirmationMode, ProviderRef, + RawJsonFlashblocksAdapter, RawJsonFlashblocksLimits, SubscriberConfig, SubscriberMode, }; #[cfg(feature = "reactive-ws")] use evm_fork_cache::reactive::{ @@ -769,6 +769,287 @@ fn sequence_failures_emit_standard_invalidations() { )); } +#[test] +fn one_missing_index_is_buffered_and_drained_in_order_inside_the_bound() { + let mut adapter = BufferedRawJsonFlashblocksAdapter::new( + ProviderRef::new("raw-json", 7), + RawJsonFlashblocksLimits::default(), + 400, + ) + .expect("reviewed 400ms gap bound"); + let first = adapter + .ingest_json_at(&index_zero(), 1_000) + .expect("index zero"); + assert_eq!(first.len(), 1); + + let buffered = adapter + .ingest_json_at(&index_two(alloy_primitives::keccak256([3_u8])), 1_100) + .expect("one missing index is held briefly"); + assert!(buffered.is_empty()); + assert_eq!(adapter.buffered_gap(), Some((1, 2, 1_500))); + + let drained = adapter + .ingest_json_at(&index_one(TX_TWO), 1_499) + .expect("late index inside the bound drains the sequence"); + let indices = drained + .iter() + .map(|update| match update { + FlashblockUpdate::Snapshot(snapshot) => snapshot.flashblock.index, + FlashblockUpdate::Invalidated(_) => None, + _ => None, + }) + .collect::>(); + assert_eq!(indices, vec![Some(1), Some(2)]); + assert_eq!(adapter.buffered_gap(), None); +} + +#[test] +fn timed_gap_drain_retains_each_frames_original_monotonic_arrival() { + let mut adapter = BufferedRawJsonFlashblocksAdapter::new( + ProviderRef::new("raw-json", 7), + RawJsonFlashblocksLimits::default(), + 400, + ) + .expect("reviewed 400ms gap bound"); + adapter + .ingest_json_timed_at(&index_zero(), 1_000) + .expect("index zero"); + assert!( + adapter + .ingest_json_timed_at(&index_two(alloy_primitives::keccak256([3_u8])), 1_100,) + .expect("future frame is retained") + .is_empty() + ); + + let drained = adapter + .ingest_json_timed_at(&index_one(TX_TWO), 1_499) + .expect("missing frame drains the retained future"); + assert_eq!(drained.len(), 2); + assert_eq!(drained[0].source_ingress_millis(), 1_499); + assert_eq!(drained[1].source_ingress_millis(), 1_100); + let FlashblockUpdate::Snapshot(first) = drained[0].update() else { + panic!("missing index must normalize to a snapshot") + }; + let FlashblockUpdate::Snapshot(second) = drained[1].update() else { + panic!("retained future frame must normalize to a snapshot") + }; + assert!(first.flashblock.same_base_identity(&second.flashblock)); +} + +#[test] +fn missing_index_timeout_revokes_only_the_speculative_payload() { + let mut adapter = BufferedRawJsonFlashblocksAdapter::new( + ProviderRef::new("raw-json", 7), + RawJsonFlashblocksLimits::default(), + 400, + ) + .expect("reviewed 400ms gap bound"); + adapter + .ingest_json_at(&index_zero(), 1_000) + .expect("index zero"); + adapter + .ingest_json_at(&index_two(alloy_primitives::keccak256([3_u8])), 1_100) + .expect("buffer index two"); + + assert!(adapter.expire_gap_at(1_499).is_none()); + let FlashblockUpdate::Invalidated(invalidation) = adapter + .expire_gap_at(1_500) + .expect("expiry revokes the provisional payload") + else { + panic!("expected invalidation") + }; + assert_eq!(invalidation.provider, ProviderRef::new("raw-json", 7)); + assert_eq!(invalidation.reason, FlashblockInvalidationReason::IndexGap); + assert!( + adapter + .ingest_json_at(&index_one(TX_TWO), 1_501) + .expect("expired payload remainder is ignored") + .is_empty() + ); +} + +#[test] +fn gap_buffer_configuration_is_bounded_to_the_reviewed_window() { + for invalid in [0, 299, 501, u64::MAX] { + assert!( + BufferedRawJsonFlashblocksAdapter::new( + ProviderRef::new("raw-json", 7), + RawJsonFlashblocksLimits::default(), + invalid, + ) + .is_err() + ); + } + for valid in [300, 400, 500] { + assert!( + BufferedRawJsonFlashblocksAdapter::new( + ProviderRef::new("raw-json", 7), + RawJsonFlashblocksLimits::default(), + valid, + ) + .is_ok() + ); + } +} + +#[test] +fn conflicting_duplicate_while_buffered_invalidates_immediately() { + let mut adapter = BufferedRawJsonFlashblocksAdapter::new( + ProviderRef::new("raw-json", 7), + RawJsonFlashblocksLimits::default(), + 400, + ) + .expect("buffered adapter"); + adapter + .ingest_json_at(&index_zero(), 1_000) + .expect("index zero"); + let third_transaction = alloy_primitives::keccak256([3_u8]); + adapter + .ingest_json_at(&index_two(third_transaction), 1_100) + .expect("buffer one future frame"); + let fourth_transaction = alloy_primitives::keccak256([4_u8]); + let conflict = String::from_utf8(index_two(fourth_transaction)) + .expect("fixture UTF-8") + .replace("\"transactions\":[\"0x03\"]", "\"transactions\":[\"0x04\"]") + .into_bytes(); + + let updates = adapter + .ingest_json_at(&conflict, 1_101) + .expect("valid same-index conflict becomes an invalidation"); + assert_eq!(updates.len(), 1); + let FlashblockUpdate::Invalidated(invalidation) = &updates[0] else { + panic!("expected conflict invalidation") + }; + assert_eq!( + invalidation.reason, + FlashblockInvalidationReason::ConflictingDuplicate + ); + assert_eq!(adapter.buffered_gap(), None); +} + +#[test] +fn a_second_future_index_fails_closed_without_growing_the_buffer() { + let mut adapter = BufferedRawJsonFlashblocksAdapter::new( + ProviderRef::new("raw-json", 7), + RawJsonFlashblocksLimits::default(), + 400, + ) + .expect("buffered adapter"); + adapter + .ingest_json_at(&index_zero(), 1_000) + .expect("index zero"); + adapter + .ingest_json_at(&index_two(alloy_primitives::keccak256([3_u8])), 1_100) + .expect("buffer one future frame"); + let index_three = String::from_utf8(index_two(alloy_primitives::keccak256([3_u8]))) + .expect("fixture UTF-8") + .replace("\"index\":2", "\"index\":3") + .into_bytes(); + + let updates = adapter + .ingest_json_at(&index_three, 1_101) + .expect("second future frame becomes an invalidation"); + let FlashblockUpdate::Invalidated(invalidation) = &updates[0] else { + panic!("expected gap invalidation") + }; + assert_eq!(invalidation.reason, FlashblockInvalidationReason::IndexGap); + assert_eq!(adapter.buffered_gap(), None); +} + +#[test] +fn reset_and_payload_replacement_clear_the_buffer() { + let source = ProviderRef::new("raw-json", 7); + let mut reset_adapter = BufferedRawJsonFlashblocksAdapter::new( + source.clone(), + RawJsonFlashblocksLimits::default(), + 400, + ) + .expect("buffered adapter"); + reset_adapter + .ingest_json_at(&index_zero(), 1_000) + .expect("index zero"); + reset_adapter + .ingest_json_at(&index_two(alloy_primitives::keccak256([3_u8])), 1_100) + .expect("buffer index two"); + assert!(matches!( + reset_adapter + .reset(ProviderRef::new("raw-json", 8)) + .expect("new source generation"), + Some(FlashblockUpdate::Invalidated(_)) + )); + assert_eq!(reset_adapter.buffered_gap(), None); + assert_eq!(reset_adapter.provider().generation, 8); + + let mut replacement_adapter = + BufferedRawJsonFlashblocksAdapter::new(source, RawJsonFlashblocksLimits::default(), 400) + .expect("buffered adapter"); + replacement_adapter + .ingest_json_at(&index_zero(), 1_000) + .expect("index zero"); + replacement_adapter + .ingest_json_at(&index_two(alloy_primitives::keccak256([3_u8])), 1_100) + .expect("buffer index two"); + let replacement = String::from_utf8(index_zero()) + .expect("fixture UTF-8") + .replace("0x1111111111111111", "0x2222222222222222") + .into_bytes(); + let updates = replacement_adapter + .ingest_json_at(&replacement, 1_101) + .expect("new index-zero payload replaces the old payload"); + assert!(matches!( + updates.as_slice(), + [FlashblockUpdate::Snapshot(_)] + )); + assert_eq!(replacement_adapter.buffered_gap(), None); +} + +#[test] +fn malformed_and_resource_exhausting_future_frames_are_never_buffered() { + let mut limits = RawJsonFlashblocksLimits::default(); + limits.max_transactions_per_payload = 1; + let mut adapter = + BufferedRawJsonFlashblocksAdapter::new(ProviderRef::new("raw-json", 7), limits, 400) + .expect("buffered adapter"); + adapter + .ingest_json_at(&index_zero(), 1_000) + .expect("index zero consumes the transaction allowance"); + assert!(matches!( + adapter.ingest_json_at(&index_two(alloy_primitives::keccak256([3_u8])), 1_100), + Err( + evm_fork_cache::reactive::RawJsonFlashblocksError::ResourceExhausted( + "transaction count" + ) + ) + )); + assert_eq!(adapter.buffered_gap(), None); + assert!(matches!( + adapter.ingest_json_at(b"{", 1_101), + Err(evm_fork_cache::reactive::RawJsonFlashblocksError::InvalidPayload(_)) + )); + assert_eq!(adapter.buffered_gap(), None); +} + +proptest! { + #[test] + fn only_one_exact_future_index_can_enter_the_reorder_buffer(index in 3_u64..64) { + let mut adapter = BufferedRawJsonFlashblocksAdapter::new( + ProviderRef::new("raw-json", 7), + RawJsonFlashblocksLimits::default(), + 400, + ) + .expect("buffered adapter"); + adapter.ingest_json_at(&index_zero(), 1_000).expect("index zero"); + let frame = String::from_utf8(index_two(alloy_primitives::keccak256([3_u8]))) + .expect("fixture UTF-8") + .replace("\"index\":2", &format!("\"index\":{index}")) + .into_bytes(); + + let updates = adapter.ingest_json_at(&frame, 1_100).expect("valid distant index"); + prop_assert!(matches!(updates.as_slice(), [FlashblockUpdate::Invalidated(_)])); + prop_assert_eq!(adapter.buffered_gap(), None); + } +} + #[test] fn joining_after_index_zero_invalidates_instead_of_inventing_a_base() { let mut adapter = adapter("raw-json", 7); diff --git a/tests/raw_json_flashblocks_runtime.rs b/tests/raw_json_flashblocks_runtime.rs index 0e9413e..e8f6da3 100644 --- a/tests/raw_json_flashblocks_runtime.rs +++ b/tests/raw_json_flashblocks_runtime.rs @@ -9,7 +9,10 @@ mod common; -use std::sync::Arc; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; use alloy_network::Ethereum; use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256}; @@ -21,12 +24,12 @@ use common::{install_mock_erc20, setup_cache_with_asserter}; use evm_fork_cache::StateUpdate; use evm_fork_cache::events::StateView; use evm_fork_cache::reactive::{ - AlloySubscriber, BlockRef, ChainStatus, DeliveryScope, EventSubscriber, FlashblockRef, - FlashblockUpdate, HandlerError, HandlerId, HandlerOutcome, InputSource, LogInterest, - PreconfirmationMode, ProviderRef, RawJsonFlashblocksAdapter, ReactiveConfig, ReactiveContext, - ReactiveEffect, ReactiveHandler, ReactiveInput, ReactiveInputBatch, ReactiveInputRecord, - ReactiveInterest, ReactiveRuntime, StateEffectQuality, SubscriberBackfill, SubscriberConfig, - SubscriberMode, + AlloySubscriber, BlockRef, ChainStatus, DeliveryScope, EventSubscriber, + FlashblockIngressTiming, FlashblockRef, FlashblockUpdate, HandlerError, HandlerId, + HandlerOutcome, InputSource, LogInterest, PreconfirmationMode, ProviderRef, + RawJsonFlashblocksAdapter, ReactiveConfig, ReactiveContext, ReactiveEffect, ReactiveHandler, + ReactiveInput, ReactiveInputBatch, ReactiveInputRecord, ReactiveInterest, ReactiveRuntime, + StateEffectQuality, SubscriberBackfill, SubscriberConfig, SubscriberMode, }; const POOL_SLOT: u64 = 0; @@ -258,12 +261,23 @@ async fn raw_preview_invalidation_replacement_and_canonical_reconciliation_are_o let first = adapter .ingest_json(&raw_frame(pool, topic, value))? .ok_or_else(|| anyhow::anyhow!("expected first raw snapshot"))?; - subscriber.ingest_flashblock_update(first)?; + let source_ingress = Instant::now() - Duration::from_millis(25); + subscriber.ingest_flashblock_update_with_ingress( + first, + FlashblockIngressTiming::new(source_ingress), + )?; let first_batch = subscriber .next_batch() .await? .ok_or_else(|| anyhow::anyhow!("expected first preconfirmation batch"))?; assert_eq!(first_batch.records().len(), 1); + assert_eq!( + first_batch + .preconfirmation_timing() + .expect("raw preview timing must survive subscriber batching") + .source_ingress(), + source_ingress + ); assert_eq!( first_batch.records()[0].context.source, InputSource::Flashblocks diff --git a/tests/reactive_alloy_subscriber.rs b/tests/reactive_alloy_subscriber.rs index 5da5189..b4043b7 100644 --- a/tests/reactive_alloy_subscriber.rs +++ b/tests/reactive_alloy_subscriber.rs @@ -7,7 +7,11 @@ use std::time::Duration; use alloy_network::Ethereum; -#[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] +#[cfg(any( + feature = "raw-flashblocks-json", + feature = "reactive-polling", + feature = "reactive-ws" +))] use alloy_primitives::U256; #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use alloy_primitives::{Address, keccak256}; @@ -34,9 +38,8 @@ use evm_fork_cache::reactive::SubscriberCapability; #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use evm_fork_cache::reactive::SubscriberOwnerError; use evm_fork_cache::reactive::{ - AlloySubscriber, DeliveryAudience, DeliveryScope, EventSubscriber, InterestOwnerSubscriber, - PendingTxInterest, ReactiveInterest, SubscriberConfig, SubscriberError, SubscriberMode, - SubscriberReconnectConfig, + AlloySubscriber, EventSubscriber, PendingTxInterest, ReactiveInterest, SubscriberConfig, + SubscriberError, SubscriberMode, SubscriberReconnectConfig, }; #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use evm_fork_cache::reactive::{BlockInterest, LogInterest}; @@ -47,6 +50,8 @@ use evm_fork_cache::reactive::{ }; #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] use evm_fork_cache::reactive::{ChainStatus, InputSource, ReactiveInput}; +#[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] +use evm_fork_cache::reactive::{DeliveryAudience, DeliveryScope, InterestOwnerSubscriber}; #[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] fn rpc_log(address: Address, topic0: B256, block_number: u64, log_index: u64) -> Log { @@ -108,7 +113,11 @@ fn polling_subscriber( ) } -#[cfg(any(feature = "reactive-polling", feature = "reactive-ws"))] +#[cfg(any( + feature = "raw-flashblocks-json", + feature = "reactive-polling", + feature = "reactive-ws" +))] fn asserter_with_chain_id() -> Asserter { let asserter = Asserter::new(); asserter.push_success(&U256::from(1)); diff --git a/tests/reactive_reorg.rs b/tests/reactive_reorg.rs index 01d18f8..8803e83 100644 --- a/tests/reactive_reorg.rs +++ b/tests/reactive_reorg.rs @@ -3790,6 +3790,12 @@ async fn unknown_parent_replacement_overwrites_the_stale_parent_blockhash() -> R .cache .block_hashes .insert(U256::from(78), stale_grandparent_hash); + let displaced_snapshot = cache.snapshot(); + assert_eq!(displaced_snapshot.block_hash(79), Some(old_parent_hash)); + assert_eq!( + displaced_snapshot.block_hash(78), + Some(stale_grandparent_hash) + ); runtime.ingest_batch( &mut cache, @@ -3807,6 +3813,22 @@ async fn unknown_parent_replacement_overwrites_the_stale_parent_blockhash() -> R )?; assert_eq!(runtime.last_canonical_block(), Some(replacement)); + let replacement_snapshot = cache.snapshot(); + assert_eq!( + replacement_snapshot.block_hash(79), + Some(replacement_parent_hash) + ); + assert_eq!(replacement_snapshot.block_hash(78), None); + assert_eq!( + displaced_snapshot.block_hash(79), + Some(old_parent_hash), + "reorg recovery must not rewrite an already issued snapshot" + ); + assert_eq!( + displaced_snapshot.block_hash(78), + Some(stale_grandparent_hash), + "reorg invalidation must remain point-in-time for prior snapshots" + ); assert_eq!( cache .unchecked_blockchain_db() diff --git a/tests/snapshot_overlay.rs b/tests/snapshot_overlay.rs index ed76935..5c5e588 100644 --- a/tests/snapshot_overlay.rs +++ b/tests/snapshot_overlay.rs @@ -11,8 +11,9 @@ mod common; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; +use alloy_eips::BlockId; use alloy_primitives::{Address, B256, Bytes, U256, keccak256}; use alloy_sol_types::{SolCall, SolValue}; use anyhow::{Result, anyhow}; @@ -23,7 +24,11 @@ use common::{ MOCK_ERC20_BALANCE_SLOT, MockERC20, install_default_account, install_mock_erc20, mock_erc20_runtime, setup_cache, transfer, }; -use evm_fork_cache::cache::{EvmOverlay, EvmSnapshot}; +use evm_fork_cache::{ + SimulationCancellationToken, + cache::{EvmOverlay, EvmSnapshot, TxConfig}, + errors::OverlayError, +}; /// The hashed storage slot of `balanceOf[owner]` for a `MockERC20` (balances at /// the declared mapping slot 3): `keccak256(abi.encode(owner, 3))`. @@ -178,6 +183,219 @@ async fn overlay_reads_reflect_snapshot_state() -> Result<()> { Ok(()) } +/// A superseded production simulation must stop after execution has genuinely +/// entered the EVM. Dropping only the async waiter is insufficient because the +/// blocking worker and its permit would continue running. The same overlay must +/// remain reusable after its cancelled checkpoint is reverted and its shared +/// memory buffer is reclaimed. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn started_evm_execution_is_cooperatively_cancelled() -> Result<()> { + use revm::state::{AccountInfo, Bytecode}; + + let mut cache = setup_cache().await?; + let caller = Address::repeat_byte(0x71); + let contract = Address::repeat_byte(0x72); + let token = Address::repeat_byte(0x73); + let owner = Address::repeat_byte(0x74); + install_default_account(&mut cache, Address::ZERO); + install_default_account(&mut cache, caller); + install_default_account(&mut cache, owner); + install_mock_erc20(&mut cache, token); + cache.insert_mapping_storage_slot( + token, + U256::from(MOCK_ERC20_BALANCE_SLOT), + owner, + U256::from(42_000_u64), + )?; + + // PUSH1 1; PUSH1 0; SSTORE; JUMPDEST; PUSH1 5; JUMP. The storage write + // proves checkpoint cleanup; the loop keeps execution inside REVM until the + // inspector observes cancellation. + let runtime = Bytecode::new_raw(Bytes::from_static(&[ + 0x60, 0x01, 0x60, 0x00, 0x55, 0x5b, 0x60, 0x05, 0x56, + ])); + cache.db_mut().insert_account_info( + contract, + AccountInfo { + code_hash: runtime.hash_slow(), + code: Some(runtime), + ..Default::default() + }, + ); + cache.insert_storage_slot(contract, U256::ZERO, U256::from(9_u64))?; + + let cancellation = SimulationCancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let snapshot = cache.snapshot(); + let worker = tokio::task::spawn_blocking(move || { + let mut overlay = EvmOverlay::new(snapshot, None); + let cancelled = overlay.call_raw_with_access_list_with_cancellation( + caller, + contract, + Bytes::new(), + &TxConfig { + gas_limit: Some(u64::MAX), + ..Default::default() + }, + &worker_cancellation, + ); + let storage_after = overlay.storage(contract, U256::ZERO)?; + let balance_after = overlay_balance_of(&mut overlay, token, owner)?; + Ok::<_, anyhow::Error>(( + cancelled, + storage_after, + balance_after, + overlay.missing_state().clone(), + overlay.blockhash_zero_fallback(), + )) + }); + + tokio::time::timeout(Duration::from_millis(250), async { + while !cancellation.has_started() { + tokio::task::yield_now().await; + } + }) + .await + .expect("the real EVM execution must start before cancellation"); + cancellation.cancel(); + + let (result, storage_after, balance_after, missing_state, blockhash_zero_fallback) = + tokio::time::timeout(Duration::from_millis(250), worker) + .await + .expect("cancelled EVM execution must release its blocking worker") + .expect("blocking worker must not panic")?; + assert!(matches!(result, Err(OverlayError::Cancelled))); + assert_eq!( + storage_after, + U256::from(9_u64), + "the cancelled SSTORE must be reverted to the snapshot value" + ); + assert_eq!( + balance_after, + U256::from(42_000_u64), + "the same overlay and reclaimed buffer must remain usable" + ); + assert!( + missing_state.is_empty(), + "reused overlay recorded unexpected missing state: {missing_state:?}" + ); + assert!(!blockhash_zero_fallback); + Ok(()) +} + +/// A scope cancelled before execution begins must reject without entering REVM, +/// and repeated cancellation requests must remain harmless. +#[tokio::test(flavor = "multi_thread")] +async fn pre_cancelled_scope_is_rejected_and_cancel_is_idempotent() -> Result<()> { + let mut cache = setup_cache().await?; + let mut overlay = EvmOverlay::new(cache.snapshot(), None); + let cancellation = SimulationCancellationToken::new(); + + cancellation.cancel(); + cancellation.cancel(); + assert!(cancellation.is_cancelled()); + assert!(!cancellation.has_started()); + + let result = overlay.call_raw_with_access_list_with_cancellation( + Address::repeat_byte(0x75), + Address::repeat_byte(0x76), + Bytes::new(), + &TxConfig::default(), + &cancellation, + ); + assert!(matches!(result, Err(OverlayError::Cancelled))); + assert!( + !cancellation.has_started(), + "a pre-cancelled scope must reject before the first EVM instruction" + ); + assert!(overlay.missing_state().is_empty()); + assert!(!overlay.blockhash_zero_fallback()); + Ok(()) +} + +/// Opting into cancellation must be byte/result-equivalent when no +/// cancellation is requested, including the captured access-list evidence. +#[tokio::test(flavor = "multi_thread")] +async fn uncancelled_execution_retains_result_and_access_list_evidence() -> Result<()> { + let mut cache = setup_cache().await?; + let caller = Address::repeat_byte(0x73); + let token = Address::repeat_byte(0x74); + let owner = Address::repeat_byte(0x75); + install_default_account(&mut cache, caller); + install_default_account(&mut cache, owner); + install_mock_erc20(&mut cache, token); + cache.insert_mapping_storage_slot( + token, + U256::from(MOCK_ERC20_BALANCE_SLOT), + owner, + U256::from(42_000_u64), + )?; + + let calldata: Bytes = MockERC20::balanceOfCall { account: owner } + .abi_encode() + .into(); + let snapshot = cache.snapshot(); + let mut baseline_overlay = EvmOverlay::new(Arc::clone(&snapshot), None); + let mut cancellable_overlay = EvmOverlay::new(snapshot, None); + let (baseline_result, baseline_access_list) = baseline_overlay.call_raw_with_access_list_with( + caller, + token, + calldata.clone(), + &TxConfig::default(), + )?; + let cancellation = SimulationCancellationToken::new(); + let (cancellable_result, cancellable_access_list) = cancellable_overlay + .call_raw_with_access_list_with_cancellation( + caller, + token, + calldata.clone(), + &TxConfig::default(), + &cancellation, + )?; + let (replayed_result, replayed_access_list) = cancellable_overlay + .call_raw_with_access_list_with_cancellation( + caller, + token, + calldata, + &TxConfig::default(), + &cancellation, + )?; + + assert_eq!(cancellable_result, baseline_result); + assert_eq!( + cancellable_access_list.accounts, + baseline_access_list.accounts + ); + assert_eq!( + cancellable_access_list.code_hashes, + baseline_access_list.code_hashes + ); + assert_eq!(cancellable_access_list.slots, baseline_access_list.slots); + assert_eq!( + cancellable_access_list.block_numbers, + baseline_access_list.block_numbers + ); + assert_eq!(replayed_result, baseline_result); + assert_eq!(replayed_access_list.accounts, baseline_access_list.accounts); + assert_eq!( + replayed_access_list.code_hashes, + baseline_access_list.code_hashes + ); + assert_eq!(replayed_access_list.slots, baseline_access_list.slots); + assert_eq!( + replayed_access_list.block_numbers, + baseline_access_list.block_numbers + ); + assert!(cancellation.has_started()); + assert!(!cancellation.is_cancelled()); + assert_eq!( + overlay_balance_of(&mut cancellable_overlay, token, owner)?, + U256::from(42_000_u64), + "the cancellable call must revert its checkpoint" + ); + Ok(()) +} + /// An offline overlay must make an unresolved storage read observable instead /// of silently treating its ZERO fallback as authoritative state. Readiness /// gates use this signal to reject an incompletely warmed speculative quote. @@ -228,7 +446,8 @@ async fn snapshot_reports_its_complete_resident_read_set() -> Result<()> { install_mock_erc20(&mut cache, token); cache.insert_storage_slot(token, U256::from(7), U256::from(9))?; - let resident = cache.snapshot().resident_read_set(); + let snapshot = cache.snapshot(); + let resident = snapshot.resident_read_set(); assert!(resident.accounts.contains(&token)); assert!( @@ -237,6 +456,10 @@ async fn snapshot_reports_its_complete_resident_read_set() -> Result<()> { .contains(&mock_erc20_runtime().hash_slow()) ); assert!(resident.slots.contains(&(token, U256::from(7)))); + assert_eq!( + snapshot.account_code_hash(token), + Some(mock_erc20_runtime().hash_slow()) + ); Ok(()) } @@ -369,7 +592,7 @@ async fn snapshot_basic_returns_none_for_notexisting_account() -> Result<()> { } #[tokio::test] -async fn snapshots_retain_resident_block_hash_dependencies_offline() -> Result<()> { +async fn snapshot_block_hash_returns_resident_dependency_offline() -> Result<()> { let mut cache = setup_cache().await?; let number = 42_u64; let hash = B256::repeat_byte(0x42); @@ -380,6 +603,7 @@ async fn snapshots_retain_resident_block_hash_dependencies_offline() -> Result<( .insert(U256::from(number), hash); let snapshot = cache.snapshot(); + assert_eq!(snapshot.block_hash(number), Some(hash)); assert!(snapshot.resident_read_set().block_numbers.contains(&number)); let mut overlay = EvmOverlay::new(snapshot, None); assert_eq!(overlay.block_hash(number)?, hash); @@ -388,3 +612,92 @@ async fn snapshots_retain_resident_block_hash_dependencies_offline() -> Result<( assert_eq!(deep.block_hash(number)?, hash); Ok(()) } + +#[tokio::test] +async fn snapshot_block_hash_does_not_infer_hash_from_block_context() -> Result<()> { + let mut cache = setup_cache().await?; + let number = 43_u64; + cache.set_block_context(Some(number), None); + + let snapshot = cache.snapshot(); + assert_eq!(snapshot.block_number(), Some(number)); + assert_eq!(snapshot.block_hash(number), None); + assert!(!snapshot.resident_read_set().block_numbers.contains(&number)); + Ok(()) +} + +#[tokio::test] +async fn snapshot_block_hash_replacement_preserves_prior_snapshot_lineage() -> Result<()> { + let mut cache = setup_cache().await?; + let number = 44_u64; + let displaced_hash = B256::repeat_byte(0x44); + let replacement_hash = B256::repeat_byte(0x45); + + cache + .db_mut() + .cache + .block_hashes + .insert(U256::from(number), displaced_hash); + let displaced_snapshot = cache.snapshot(); + + cache + .db_mut() + .cache + .block_hashes + .insert(U256::from(number), replacement_hash); + let replacement_snapshot = cache.snapshot(); + + assert_eq!(displaced_snapshot.block_hash(number), Some(displaced_hash)); + assert_eq!( + replacement_snapshot.block_hash(number), + Some(replacement_hash) + ); + assert_eq!( + displaced_snapshot.block_hash(number), + Some(displaced_hash), + "a replacement branch must not rewrite a previously issued snapshot" + ); + Ok(()) +} + +#[tokio::test] +async fn snapshot_block_context_hash_is_hash_pinned_and_immutable() -> Result<()> { + let mut cache = setup_cache().await?; + let displaced_hash = B256::repeat_byte(0x51); + let replacement_hash = B256::repeat_byte(0x52); + + cache.set_block(BlockId::from((displaced_hash, Some(true)))); + cache.set_block_context(Some(51), None); + let displaced_snapshot = cache.snapshot(); + let displaced_deep = cache.snapshot_deep_clone(); + + cache.set_block(BlockId::from((replacement_hash, Some(true)))); + cache.set_block_context(Some(51), None); + let replacement_snapshot = cache.snapshot(); + + assert_eq!( + displaced_snapshot.block_context_hash(), + Some(displaced_hash) + ); + assert_eq!(displaced_deep.block_context_hash(), Some(displaced_hash)); + assert_eq!( + replacement_snapshot.block_context_hash(), + Some(replacement_hash) + ); + assert_eq!( + displaced_snapshot.block_context_hash(), + Some(displaced_hash), + "repinning the live cache must not rewrite an issued snapshot's lineage" + ); + Ok(()) +} + +#[tokio::test] +async fn snapshot_block_context_hash_is_absent_for_number_pins() -> Result<()> { + let mut cache = setup_cache().await?; + cache.set_block(BlockId::number(52)); + + assert_eq!(cache.snapshot().block_context_hash(), None); + assert_eq!(cache.snapshot_deep_clone().block_context_hash(), None); + Ok(()) +} From 9c5f820d7df0e845244aa101c684e13a600b8c06 Mon Sep 17 00:00:00 2001 From: Kai Aldag Date: Sat, 22 Aug 2026 11:26:02 -0700 Subject: [PATCH 8/8] feat(reactive): source canonical logs from the subscription The pubsub log stream already delivers every canonical log, but consumers could not trust it: alloy's `Subscription::into_stream()` silently drops lagged and undecodable notifications, so a punctured stream is indistinguishable from a whole one. Consumers compensated by re-fetching every block's logs over hash-pinned `eth_getLogs` -- measured at one request per canonical block per chain, roughly 93.6k a day across three chains -- while the socket carried the same data. Make the loss observable, then attest its absence. * Gap detection. The log, header and pending-log streams consume `Subscription::into_raw()` and match on `RecvError::Lagged`, surfacing `SubscriberEvent::StreamGap`. Recovery is per-source: a log gap backfills the affected window, a header gap is counted (the consumer's lineage walk self-heals), a preconfirmation gap invalidates the flashblock snapshot. * Coverage attestation. `ChainControl::LogCoverage` carries a negative guarantee -- no log-notification loss went unhealed at or below this block. It is deliberately not a positive claim; that is unprovable from a stream. Validation rejects a watermark that regresses, outruns canonical coverage, or names a block whose identity disagrees with retained history, and it is never inferred from silence. The attestation is necessary but not sufficient for a consumer deciding a block's log set is closed, and its documentation now says so explicitly: that decision needs ordering evidence from the log stream itself, or a positive proof of absence. A header for a later block is not such evidence, because `newHeads` is an independent subscription; nor is a timer. * RPC accounting. `SubscriberRpcStats` counts every request the subscriber issues by method and cause, so background traffic is measurable rather than inferred from a provider dashboard. * Head-poll suppression. The canonical head poll stands down while flashblock sealing already establishes the head, and certifies on a sealed block instead. Measured against 0.4.0-alpha.4 over two 70-minute three-chain dry-runs: `eth_getLogs` fell from 4,525 to 692 per 70 minutes, and total JSON-RPC traffic from 7,893 to 4,544. Breaking: `SubscriberConfig` gains `log_channel_size`; `ChainControl` gains a variant that exhaustive matches must handle. Checkpoint formats change -- delivery witness 1 -> 2, durable runtime checkpoint 3 -> 4, cache checkpoint 6 -> 7 -- so existing checkpoints are rejected as `InvalidFormat` and must be rebuilt. --- CHANGELOG.md | 113 +- Cargo.lock | 3 +- Cargo.toml | 15 +- examples/bulk_storage_bench.rs | 2 +- scripts/check-security-exceptions.sh | 2 +- src/bulk_storage.rs | 8 +- src/cache/durable_checkpoint.rs | 5 +- src/lib.rs | 2 + src/reactive/mod.rs | 1980 ++++++++++++++++++++++++- tests/durable_checkpoint.rs | 10 +- tests/raw_json_flashblocks.rs | 11 +- tests/raw_json_flashblocks_runtime.rs | 20 +- tests/reactive_log_coverage.rs | 250 ++++ tests/reactive_rpc_stats.rs | 367 +++++ tests/reactive_runtime.rs | 2 +- 15 files changed, 2736 insertions(+), 54 deletions(-) create mode 100644 tests/reactive_log_coverage.rs create mode 100644 tests/reactive_rpc_stats.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d18282..5558a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,118 @@ versions (`0.x.0`); patch versions (`0.x.y`) are non-breaking. The roadmap in [`docs/ROADMAP.md`](docs/ROADMAP.md) deliberately reshapes the API before the surface freezes at 1.0. -## [Unreleased] +## [0.4.0-alpha.5] - 2026-08-22 + +### Migration checklist + +- Add the new `SubscriberConfig::log_channel_size` field, or use + `..SubscriberConfig::default()` in struct literals. `None` preserves the + previous behaviour of sizing log subscriptions from `max_batch_size`. +- Handle `ChainControl::LogCoverage` and `CanonicalSequenceMutation::LogCoverage` + in any exhaustive match. Both enums are `#[non_exhaustive]`, so a wildcard arm + already compiles; a source that cannot prove the attestation should ignore the + control rather than treat it as canonical progress. +- Existing durable checkpoints are rejected as `InvalidFormat` and must be + rebuilt. `CHECKPOINT_VERSION` is now 7, the reactive runtime blob 4, and the + delivery witness 2, because each independently encodes state or controls whose + shape changed. A stale file fails closed instead of being decoded against the + newer layout. + +### Changed + +- Canonical head certification is now driven by the flashblock stream rather than + by a blind interval. A Flashblocks endpoint replaces the `newHeads` + subscription with a fixed-interval certification poll, because its `newHeads` + may carry partial heads — but the timer spent a request every interval whether + or not a block had sealed, so on a chain whose blocks are slower than the + interval most of those requests bought nothing. + + A `newFlashblocks` payload opening a new block proves the previous one sealed, + which is the moment a certification is worth spending; the timer now suppresses + itself when a certification already happened inside its window. On Base this is + roughly one request per block instead of one per interval, and the head is + detected sooner because the trigger is a signal rather than a deadline. + + `SubscriberConfig::canonical_head_poll_interval` is unchanged and keeps its + meaning as the liveness fallback: with no flashblock stream to drive + certification, nothing is suppressed and polling behaves exactly as before. + `FlashblocksRpcMetrics::suppressed_canonical_head_polls` reports how often the + timer stood down. + +### Fixed + +- Pubsub log, block-header, and OP Stack `pendingLogs` streams no longer lose + notifications silently. `alloy-pubsub`'s typed subscription stream treats both + a lagged broadcast receiver and an undecodable payload as `continue`, logging + at `debug` and moving on, so a bounded-channel overflow discarded canonical + logs with no error, no counter, and nothing a consumer could act on — while + still reporting an apparently complete stream. These streams now consume the + raw subscription, so a dropped notification becomes an observable gap and a + closed channel still terminates the stream for the existing reconnect path. + + Recovery is scoped to what each stream costs. A canonical log gap refetches + exactly that source's window from its delivery anchor to the current head, + attributed to the new `SubscriberRpcCause::GapBackfill` so backpressure loss + is distinguishable from reconnect churn; a gap with no anchor yet fails closed + rather than continuing past known loss. A header gap is counted but not + refetched, because a consumer that walks a replacement header's parent lineage + back to retained canonical history already recovers the skipped blocks. A + pre-confirmation gap discards the speculative snapshot instead of publishing a + punctured preview. + +### Added + +- Added `ChainControl::LogCoverage`, a watermark attesting that no + log-notification loss went unhealed at or below the named block, plus + `ReactiveRuntime::log_coverage_head`, + `CanonicalSequenceState::with_log_coverage_head` / `log_coverage_head`, + `CanonicalSequenceMutation::LogCoverage`, and + `SubscriberCapability::LogCoverageAttestation`. + + The guarantee is deliberately negative. A source cannot prove from its own log + stream that every matching log through block `N` arrived — a filter that + matched nothing for a hundred blocks is indistinguishable from one whose + notifications were dropped — but it can prove it detected no loss it did not + repair, which is exactly the fact a consumer cannot establish for itself. A + consumer combines the watermark with its own ordering evidence to decide when a + block's log set is closed. + + The attestation makes no claim about chain progress and never advances the + cache's pinned block or the canonical coverage head. Validation rejects a + watermark that regresses, that outruns canonical coverage, or whose identity + disagrees with retained history; it is never inferred, so `None` means unknown + rather than complete, and a source that cannot detect loss neither advertises + the capability nor emits the control. `AlloySubscriber` attests only on the + pubsub transport, and withdraws a pending attestation when a gap is detected so + a block observed before the loss was known is never vouched for. The watermark + is carried through validation snapshots, staged mutations, and durable + checkpoints so it survives restore. +- Added `AlloySubscriber::stream_gap_stats`, `SubscriberStreamGapStats`, and + `SubscriberStreamGap`, reporting notification loss observed on live + subscriptions and what healing it cost: lagged and undecodable notification + counts, canonical log gaps healed, header gaps, and pre-confirmation gaps. A + subscription reporting zeroes here is the evidence that its delivered log set + is complete, which is what makes treating the stream as authoritative safe + rather than optimistic. `reset_stream_gap_stats` opens a bounded window. +- Added `SubscriberConfig::log_channel_size` so pubsub log backpressure can be + sized independently of `max_batch_size`. A high-volume filter sharing a + subscriber with small delivery batches previously had no way to buy headroom + without also enlarging every delivered batch. +- Added `AlloySubscriber::rpc_stats` and `SubscriberRpcStats`, a complete account + of every provider request the reactive stack issues, attributed to both the + JSON-RPC method (`SubscriberRpcMethod`) and the mechanism responsible for it + (`SubscriberRpcCause`). Previously only the Flashblocks path was counted, so + canonical-path consumption — subscription installs, chain identity, owner + reconciliation, lazy and reconnect backfills, log-context verification, and + canonical head certification — was invisible from inside the process and + observable only on a provider invoice. Bulk owner catch-up and the free + functions it calls record through a shared counter, so a request is charged to + the mechanism that asked for it rather than to the helper both mechanisms + share. Counts are cumulative for the subscriber's lifetime and survive + reconnects and delivery-state resets; `reset_rpc_stats` opens a bounded + measurement window. This differs deliberately from `FlashblocksRpcMetrics`, + which stays scoped to one Flashblocks generation and remains the place for + outcomes that are not request counts. ## [0.4.0-alpha.4] - 2026-08-11 diff --git a/Cargo.lock b/Cargo.lock index 3c804b6..b9adc06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2061,7 +2061,7 @@ dependencies = [ [[package]] name = "evm-fork-cache" -version = "0.4.0-alpha.4" +version = "0.4.0-alpha.5" dependencies = [ "alloy-consensus", "alloy-contract", @@ -2071,6 +2071,7 @@ dependencies = [ "alloy-node-bindings", "alloy-primitives", "alloy-provider", + "alloy-pubsub", "alloy-rlp", "alloy-rpc-client", "alloy-rpc-types-eth", diff --git a/Cargo.toml b/Cargo.toml index 3a3fdf6..ba2232a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "evm-fork-cache" -version = "0.4.0-alpha.4" +version = "0.4.0-alpha.5" edition = "2024" rust-version = "1.90" license = "MIT OR Apache-2.0" @@ -39,7 +39,12 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = ["reactive", "reactive-ws"] reactive = [] -reactive-ws = ["reactive", "alloy-provider/ws", "dep:rustls", "rustls/ring"] +# `tokio/sync` exposes `broadcast::error::RecvError`, which the pubsub log and +# header streams match on so a dropped notification is observable instead of +# being silently skipped. alloy-pubsub already enables it, but relying on +# feature unification from a transitive dependency would break a build that +# resolved it differently. +reactive-ws = ["reactive", "alloy-provider/ws", "dep:rustls", "rustls/ring", "tokio/sync", "dep:alloy-pubsub"] reactive-polling = ["reactive"] # Receipt-enriched indexed JSON conversion plus a bounded in-process handoff. # The application owns the source socket and its complete lifecycle; this @@ -52,6 +57,12 @@ alloy-contract = ">=1.0.38, <1.7" alloy-eips = ">=1.0.38, <1.7" alloy-network = ">=1.0.38, <1.7" alloy-primitives = { version = ">=1.4, <1.7", features = ["map"] } +# Pulled by `reactive-ws` only. `alloy-provider/ws` already brings this in; it is +# declared explicitly because the pubsub log and header streams consume +# `Subscription::into_raw()` directly, so a dropped notification surfaces as a +# `broadcast::error::RecvError::Lagged` instead of being silently skipped by +# `Subscription::into_stream()`. +alloy-pubsub = { version = ">=1.0.38, <1.7", optional = true } alloy-provider = ">=1.0.38, <1.7" alloy-rlp = "0.3" alloy-rpc-client = ">=1.0.38, <1.7" diff --git a/examples/bulk_storage_bench.rs b/examples/bulk_storage_bench.rs index 6fa354f..a7b309d 100644 --- a/examples/bulk_storage_bench.rs +++ b/examples/bulk_storage_bench.rs @@ -758,7 +758,7 @@ async fn scenario_custom_program( .map(|i| (USDC_WETH_V3_POOL, U256::from(POOL_OBSERVATIONS_SLOT + i))) .collect(); let expected = fetch_map(bulk, &ring_slots, block)?; - for (i, chunk) in bytes.chunks_exact(32).enumerate() { + for (i, chunk) in bytes.as_chunks::<32>().0.iter().enumerate() { let key = ( USDC_WETH_V3_POOL, U256::from(POOL_OBSERVATIONS_SLOT + i as u64), diff --git a/scripts/check-security-exceptions.sh b/scripts/check-security-exceptions.sh index 6391b60..f2ecf0e 100755 --- a/scripts/check-security-exceptions.sh +++ b/scripts/check-security-exceptions.sh @@ -159,7 +159,7 @@ bincode_graph="$({ } | sed -E 's# \(/[^)]*\)$##; s# \(\*\)$##')" expected_bincode_graph="$(printf '%s\n' \ '0bincode v1.3.3' \ - '1evm-fork-cache v0.4.0-alpha.4')" + '1evm-fork-cache v0.4.0-alpha.5')" if [[ "$bincode_graph" != "$expected_bincode_graph" ]]; then echo "The accepted bincode 1 compatibility scope changed." >&2 echo "Expected:" >&2 diff --git a/src/bulk_storage.rs b/src/bulk_storage.rs index 1959f10..925c2fd 100644 --- a/src/bulk_storage.rs +++ b/src/bulk_storage.rs @@ -273,7 +273,13 @@ pub fn decode_packed_values(data: &[u8], expected: usize) -> Option> { if data.len() != expected * 32 { return None; } - Some(data.chunks_exact(32).map(U256::from_be_slice).collect()) + Some( + data.as_chunks::<32>() + .0 + .iter() + .map(|chunk| U256::from_be_slice(chunk)) + .collect(), + ) } /// ABI-encode one `aggregate3` dispatch whose subcalls run the extractor at diff --git a/src/cache/durable_checkpoint.rs b/src/cache/durable_checkpoint.rs index a9d2e5a..3c5d924 100644 --- a/src/cache/durable_checkpoint.rs +++ b/src/cache/durable_checkpoint.rs @@ -31,7 +31,10 @@ use super::{ }; const CHECKPOINT_MAGIC: &[u8; 8] = b"EFCCKPT\0"; -const CHECKPOINT_VERSION: u32 = 6; +// 7: the reactive runtime blob gained `log_coverage_head`, and `ChainControl` +// gained `LogCoverage`. A version 6 file is rejected as `InvalidFormat` +// rather than decoded against the newer shape. +const CHECKPOINT_VERSION: u32 = 7; const CHECKPOINT_LABEL: &str = "durable reactive checkpoint"; const CHECKPOINT_CHECKSUM_BYTES: usize = 32; const CHECKPOINT_HEADER_BYTES: u64 = diff --git a/src/lib.rs b/src/lib.rs index c032b4f..26ed1dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -225,6 +225,8 @@ pub use reactive::{ CheckpointedIngest, InterestOwnerSubscriber, ReactiveBaselineError, ReactiveCanonicalBaseline, ReactiveCheckpointRestoreError, ReactiveConfig, ReactiveEngine, ReactiveEngineError, ReactiveEngineRegisterError, ReactiveHandler, ReactiveRuntime, SubscriberPayloadCommitment, + SubscriberRpcCause, SubscriberRpcMethod, SubscriberRpcStats, SubscriberStreamGap, + SubscriberStreamGapStats, }; pub use state_update::{ AccountChange, AccountPatch, PurgeRecord, PurgeScope, SkippedAccountPatch, SkippedBalanceDelta, diff --git a/src/reactive/mod.rs b/src/reactive/mod.rs index 00f5967..e9617d2 100644 --- a/src/reactive/mod.rs +++ b/src/reactive/mod.rs @@ -49,6 +49,8 @@ use futures::{ future::{Either, poll_fn, select}, stream::{BoxStream, FuturesUnordered}, }; +#[cfg(feature = "reactive-ws")] +use tokio::sync::broadcast; use crate::{ cache::{ @@ -724,6 +726,30 @@ pub enum ChainControl { /// and installs known `NUMBER`/timestamp values, but clears unproven /// header-only environment fields such as base fee and beneficiary. CanonicalProgress(BlockRef), + /// Attest that no notification loss has gone unhealed on any log source at + /// or below this block. + /// + /// This is a *negative* guarantee, and deliberately so. A source cannot + /// prove from its own log stream that every matching log through block `N` + /// arrived — a filter that matched nothing for a hundred blocks is + /// indistinguishable from one whose notifications were dropped. What a + /// source can prove is that it detected no loss it did not repair, which is + /// exactly the fact a consumer cannot establish for itself. + /// + /// A consumer combines this with its own ordering evidence to decide when a + /// block's log set is closed. That evidence must come from the log stream + /// itself — a delivered log for a strictly later block — or from a positive + /// proof of absence such as the block's `logsBloom` excluding every + /// interest. A header for a later block is *not* such evidence: `newHeads` + /// is an independent subscription, so it establishes nothing about whether + /// an earlier block's logs have been delivered. Neither is a timer. Sources that cannot make this promise simply + /// never emit it, and advertise the absence through + /// [`SubscriberCapability::LogCoverageAttestation`]; that distinction is why + /// silence here must never be read as an attestation. + /// + /// Unlike [`Self::CanonicalProgress`] this makes no claim about chain + /// progress and never advances the cache's pinned block. + LogCoverage(BlockRef), /// Ordered cutover or synchronization fence. Barrier { /// Subscriber-defined opaque barrier identity. @@ -757,6 +783,7 @@ pub struct CanonicalSequenceState { coverage_head: Option, safe_head: Option, finalized_head: Option, + log_coverage_head: Option, } impl CanonicalSequenceState { @@ -776,9 +803,21 @@ impl CanonicalSequenceState { coverage_head, safe_head, finalized_head, + log_coverage_head: None, } } + /// Seed the attested log-coverage watermark. + /// + /// Kept separate from [`Self::new`] so restoring durable state that predates + /// log-coverage attestation stays a compile-time no-op: an absent watermark + /// means "no source has attested", never "attested at genesis". + #[must_use] + pub fn with_log_coverage_head(mut self, log_coverage_head: Option) -> Self { + self.log_coverage_head = log_coverage_head; + self + } + /// Sparse retained canonical history in ascending processing order. pub fn retained_canonical_history(&self) -> &[BlockRef] { &self.retained_canonical_history @@ -799,6 +838,15 @@ impl CanonicalSequenceState { self.finalized_head.as_ref() } + /// Highest block at or below which no log-notification loss went unhealed. + /// + /// `None` means no source has attested, which is not an attestation of + /// anything: treat it as unknown, never as complete. See + /// [`ChainControl::LogCoverage`]. + pub const fn log_coverage_head(&self) -> Option<&BlockRef> { + self.log_coverage_head.as_ref() + } + /// Retain at most the newest `max_entries` canonical history identities. /// /// Coverage and safe/finalized heads are unchanged. The oldest retained @@ -854,6 +902,8 @@ pub enum CanonicalSequenceMutation { Safe(BlockRef), /// Accept a finalized-head update with metadata resolved against prior state. Finalized(BlockRef), + /// Advance the attested log-coverage watermark. + LogCoverage(BlockRef), } /// Successful result of provider-neutral canonical envelope validation. @@ -2554,6 +2604,16 @@ pub enum RootGateCadence { impl RootGateCadence { /// Probe at most once every `n` canonical blocks, clamping `0` to `1`. + /// + /// # Cost + /// + /// Each firing issues one `eth_getProof` per tracked account — the most + /// expensive read this crate makes — so the request rate is + /// `tracked accounts / n` per canonical block. A few hundred tracked + /// accounts on a fast chain is a substantial standing budget. The gate is + /// inert until [`ReactiveRuntime::track_account`] is called, so a runtime + /// that never tracks accounts never pays it. + #[must_use] pub fn every_n_blocks(n: u64) -> Self { Self::EveryNBlocks(NonZeroU64::new(n.max(1)).expect("clamped to at least 1")) } @@ -3614,6 +3674,9 @@ pub struct ReactiveRuntime { config: ReactiveConfig, journal: VecDeque>, coverage_head: Option, + /// Highest block a source has attested carries no unhealed log-notification + /// loss. Never inferred: absent until a source says so. + log_coverage_head: Option, pending_resyncs: Vec, health: CacheHealth, safe_head: Option, @@ -3668,7 +3731,8 @@ struct BlockJournal { rollback_diffs: Vec, } -const DURABLE_RUNTIME_CHECKPOINT_VERSION: u32 = 3; +// 4: adds `log_coverage_head`, the attested log-completeness watermark. +const DURABLE_RUNTIME_CHECKPOINT_VERSION: u32 = 4; #[derive(serde::Serialize, serde::Deserialize)] struct DurableRuntimeCheckpoint { @@ -3678,6 +3742,7 @@ struct DurableRuntimeCheckpoint { health: CacheHealth, pending_resyncs: Vec, coverage_head: Option, + log_coverage_head: Option, journal: Vec, freshness: Option, tracking: HashMap, @@ -3713,6 +3778,7 @@ impl DurableRuntimeRestorePlan { struct ReactiveRuntimeState { journal: VecDeque>, coverage_head: Option, + log_coverage_head: Option, pending_resyncs: Vec, health: CacheHealth, safe_head: Option, @@ -4098,6 +4164,7 @@ impl ReactiveRuntime { config, journal: VecDeque::new(), coverage_head: None, + log_coverage_head: None, pending_resyncs: Vec::new(), health: CacheHealth::Healthy, safe_head: None, @@ -4117,6 +4184,7 @@ impl ReactiveRuntime { ReactiveRuntimeState { journal: self.journal.clone(), coverage_head: self.coverage_head, + log_coverage_head: self.log_coverage_head, pending_resyncs: self.pending_resyncs.clone(), health: self.health, safe_head: self.safe_head, @@ -4176,6 +4244,7 @@ impl ReactiveRuntime { fn restore_state(&mut self, state: ReactiveRuntimeState) { self.journal = state.journal; self.coverage_head = state.coverage_head; + self.log_coverage_head = state.log_coverage_head; self.pending_resyncs = state.pending_resyncs; self.health = state.health; self.safe_head = state.safe_head; @@ -4207,6 +4276,7 @@ impl ReactiveRuntime { health: self.health, pending_resyncs: self.pending_resyncs.clone(), coverage_head: self.coverage_head, + log_coverage_head: self.log_coverage_head, journal: self .journal .iter() @@ -4286,6 +4356,7 @@ impl ReactiveRuntime { self.health = checkpoint.health; self.pending_resyncs = checkpoint.pending_resyncs; self.coverage_head = checkpoint.coverage_head; + self.log_coverage_head = checkpoint.log_coverage_head; self.journal = checkpoint .journal .into_iter() @@ -4488,6 +4559,13 @@ impl ReactiveRuntime { /// [`ReactiveReport::CoverageGap`] and schedules a /// [`ResyncReason::RootMoved`] repair. [`Slots`](TrackingPolicy::Slots) /// accounts are never root-gated (spec Decision 3). + /// # Cost + /// + /// Tracking an account with a root-gated policy enrols it in the root gate, + /// which issues one `eth_getProof` per tracked account every + /// [`RootGateCadence`] window. That is the most expensive read this crate + /// makes, and it is standing traffic for as long as the account is tracked; + /// [`TrackingPolicy::Slots`] opts out of the gate entirely. pub fn track_account(&mut self, address: Address, policy: TrackingPolicy) { self.tracking.insert(address, policy); self.tracked_roots.remove(&address); @@ -4514,6 +4592,17 @@ impl ReactiveRuntime { self.touched_since_gate.clear(); } + /// Highest block a source has attested carries no unhealed log-notification + /// loss, when any source has attested. + /// + /// `None` means unknown, not complete. A consumer deciding whether it may + /// treat a buffered log set as authoritative must require a watermark at or + /// above the block in question — never infer completeness from silence. See + /// [`ChainControl::LogCoverage`]. + pub const fn log_coverage_head(&self) -> Option<&BlockRef> { + self.log_coverage_head.as_ref() + } + /// The configured [`RootGateCadence`]. pub fn root_gate_cadence(&self) -> RootGateCadence { self.root_gate_cadence @@ -5800,6 +5889,11 @@ impl ReactiveRuntime { advance_or_enrich_coverage(&mut self.coverage_head, &enriched); self.trim_journal(); } + ChainControl::LogCoverage(block) => { + // An attestation, not progress: never advances the pinned block + // or the canonical coverage head. + set_or_enrich_block_ref(&mut self.log_coverage_head, block); + } ChainControl::Barrier { block: None, .. } => {} ChainControl::Reorg { common_ancestor, @@ -5913,7 +6007,8 @@ impl ReactiveRuntime { self.coverage_head, self.safe_head, self.finalized_head, - ); + ) + .with_log_coverage_head(self.log_coverage_head); let record_metadata = records .iter() .map(|(record, _, scope)| (record, *scope)) @@ -7216,6 +7311,14 @@ fn validate_canonical_sequence_parts( } mutations.push(CanonicalSequenceMutation::Canonical(*block)); } + ChainControl::LogCoverage(block) => { + set_or_enrich_block_ref(&mut state.log_coverage_head, block); + mutations.push(CanonicalSequenceMutation::LogCoverage( + state + .log_coverage_head + .expect("log coverage head was just installed"), + )); + } ChainControl::Barrier { block: None, .. } => {} ChainControl::Reorg { .. } => { unreachable!("phase validation excludes post-record reorg controls") @@ -7509,6 +7612,31 @@ fn validate_sequence_control( ))); } } + ChainControl::LogCoverage(block) => { + validate_sequence_known_identity(state, block, "log coverage")?; + // Monotonic: an attestation may be re-sent for the same block but + // must never retreat, or a consumer could widen a window it had + // already narrowed. + if let Some(current) = state.log_coverage_head.as_ref() + && (block.number < current.number + || (block.number == current.number && block.hash != current.hash)) + { + return Err(invalid(format!( + "log coverage {}:{:?} conflicts with current {}:{:?}", + block.number, block.hash, current.number, current.hash + ))); + } + // Logs cannot be attested complete for a block the source has not + // established canonical coverage for. + if let Some(coverage) = state.coverage_head.as_ref() + && block.number > coverage.number + { + return Err(invalid(format!( + "log coverage {}:{:?} is ahead of canonical coverage {}:{:?}", + block.number, block.hash, coverage.number, coverage.hash + ))); + } + } ChainControl::Barrier { block: None, .. } => {} ChainControl::Reorg { common_ancestor, @@ -8120,9 +8248,11 @@ fn canonical_coverage_control_block(control: &ChainControl) -> Option<&BlockRef> | ChainControl::Barrier { block: Some(block), .. } => Some(block), + // An attestation names a canonical block but claims no progress to it. ChainControl::Reorg { .. } | ChainControl::Safe(_) | ChainControl::Finalized(_) + | ChainControl::LogCoverage(_) | ChainControl::Barrier { block: None, .. } => None, } } @@ -8132,6 +8262,7 @@ fn chain_control_canonical_assertion(control: &ChainControl) -> Option<&BlockRef ChainControl::Safe(block) | ChainControl::Finalized(block) | ChainControl::CanonicalProgress(block) + | ChainControl::LogCoverage(block) | ChainControl::Barrier { block: Some(block), .. } => Some(block), @@ -8147,6 +8278,7 @@ fn assert_chain_control_identities( ChainControl::Safe(block) | ChainControl::Finalized(block) | ChainControl::CanonicalProgress(block) + | ChainControl::LogCoverage(block) | ChainControl::Barrier { block: Some(block), .. } => assert_canonical_block_identity(asserted_blocks, block, "chain control"), @@ -9801,6 +9933,15 @@ pub enum SubscriberCapability { HistoricalBackfill, /// Follow live chain data. Live, + /// Attest, via [`ChainControl::LogCoverage`], that no log-notification loss + /// went unhealed at or below a stated block. + /// + /// Advertise this only when loss is actually detectable: a source that can + /// silently drop a notification must not claim it, because a consumer treats + /// the capability as licence to trust a delivered log set instead of + /// re-fetching it. Absence of the capability and absence of an attestation + /// mean the same thing — unknown — and neither may be read as complete. + LogCoverageAttestation, /// Recover the complete committed consumer position after reconnect or /// restart, including any unacknowledged delivery. /// @@ -10045,6 +10186,27 @@ pub struct SubscriberConfig { /// `pending` RPC reads, so one generation-pinned sampler reads the /// cumulative pending block, its exact hash-addressed parent, filtered /// pending-block logs, and bounded exact transaction receipts. + /// + /// # Cost + /// + /// This is the most request-hungry setting in the crate, and unlike the + /// canonical paths it cannot be made event-driven: the pending surface is + /// only observable by asking. Every tick issues one pending-block read, one + /// `eth_getLogs` **per provider-facing log filter**, and up to + /// [`Self::max_pending_transaction_receipts_per_tick`] receipt calls. At the + /// 250 ms default that is four ticks a second, bounded overall by + /// [`Self::max_flashblock_rpc_requests_per_second`] — a ceiling of 40 + /// requests per second, or roughly 3.4 M per day on one chain. + /// + /// It is reached only by enabling pre-confirmations on a chain whose adapter + /// samples pending state (Optimism and its testnet), which in a transport + /// configuration means marking one of that chain's endpoints + /// `flashblocks = true`. Raise this interval, lower + /// `max_flashblock_rpc_requests_per_second`, or leave + /// [`Self::preconfirmations`] disabled if that budget is not intended; + /// [`AlloySubscriber::rpc_stats`] attributes the traffic to + /// [`SubscriberRpcCause::PendingStateSample`] so it is visible before it + /// arrives on an invoice. pub flashblock_poll_interval: Duration, /// Consecutive pending-state request failure allowance. /// @@ -10070,11 +10232,32 @@ pub struct SubscriberConfig { /// ticks. The default leaves headroom below common paid-provider limits of /// 50 requests per second. pub max_flashblock_rpc_requests_per_second: usize, + /// Bounded notification capacity for pubsub log streams. + /// + /// `None` reuses [`Self::max_batch_size`]. Size this independently when a + /// high-volume log filter shares a subscriber with small delivery batches: + /// the transport drops notifications once the channel is full, and while + /// that loss is now detected and healed by an exact-window refetch, each + /// occurrence costs an `eth_getLogs`. Watch + /// [`SubscriberStreamGapStats::lagged_notifications`] to tell whether this + /// is too small. + pub log_channel_size: Option, /// Hydrate pending transaction hashes into full bodies when possible. pub hydrate_pending_transactions: bool, /// Verify each canonical log's block identity through RPC and enrich its /// context with the exact parent hash before delivery. /// + /// # This is not the way to trust a log stream + /// + /// Enabling this to decide whether delivered logs can be trusted is the + /// expensive wrong answer: it costs a request per distinct canonical block + /// and proves strictly less than [`ChainControl::LogCoverage`], which is + /// free. Verification confirms that each log it *received* names a real + /// block; it says nothing about logs that never arrived, which is the + /// failure that matters. Use the attestation for completeness, and reserve + /// this for a strict coordinator that needs exact parent-hash enrichment on + /// log-only pubsub events. + /// /// Enable this when a strict coordinator (such as a hybrid historical/live /// source) must prove canonical ancestry from log-only pubsub events. /// Verification is cached per block, so the provider is queried at most @@ -10109,6 +10292,7 @@ pub struct SubscriberConfig { impl Default for SubscriberConfig { fn default() -> Self { Self { + log_channel_size: None, preconfirmations: PreconfirmationMode::Disabled, canonical_head_poll_interval: Duration::from_millis(500), canonical_head_request_timeout: Duration::from_secs(3), @@ -10154,6 +10338,7 @@ pub struct FlashblocksRpcMetrics { pending_receipts_unavailable: u64, failed_requests: u64, raced_samples: u64, + suppressed_canonical_head_polls: u64, } impl FlashblocksRpcMetrics { @@ -10205,6 +10390,16 @@ impl FlashblocksRpcMetrics { self.failed_requests } + /// Timer-driven canonical head polls that issued no request because the + /// flashblock stream had already certified the head inside the poll window. + /// + /// On a chain whose flashblock cadence is faster than the poll interval, + /// most ticks land here: the certification is event-driven and the timer is + /// only a liveness fallback. + pub const fn suppressed_canonical_head_polls(self) -> u64 { + self.suppressed_canonical_head_polls + } + /// Samples discarded because the pending-log response advanced beyond /// the separately fetched cumulative block. The next tick retries from a /// fresh block/log pair; no partial speculative view is published. @@ -10224,6 +10419,499 @@ impl FlashblocksRpcMetrics { } } +/// JSON-RPC method issued by the reactive stack on a consumer's behalf. +/// +/// `EthSubscribe` covers every `eth_subscribe`/`eth_newFilter` handshake the +/// subscriber performs when it installs a stream source, including the OP Stack +/// `newFlashblocks` and `pendingLogs` channels. Notifications delivered over an +/// established subscription are not requests and are not counted here. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum SubscriberRpcMethod { + /// `eth_chainId`. + EthChainId, + /// `eth_blockNumber`. + EthBlockNumber, + /// `eth_getBlockByNumber`. + EthGetBlockByNumber, + /// `eth_getBlockByHash`. + EthGetBlockByHash, + /// `eth_getLogs`. + EthGetLogs, + /// `eth_getTransactionReceipt`. + EthGetTransactionReceipt, + /// Stream installation: `eth_subscribe`, or the polling transport's + /// `eth_newFilter` handshake. + EthSubscribe, + /// `op_supportedCapabilities`. + OpSupportedCapabilities, +} + +impl SubscriberRpcMethod { + /// Every method the reactive stack can issue, in reporting order. + pub const ALL: [Self; 8] = [ + Self::EthChainId, + Self::EthBlockNumber, + Self::EthGetBlockByNumber, + Self::EthGetBlockByHash, + Self::EthGetLogs, + Self::EthGetTransactionReceipt, + Self::EthSubscribe, + Self::OpSupportedCapabilities, + ]; + + /// Number of distinct methods. + pub const COUNT: usize = Self::ALL.len(); + + /// Wire name, suitable for a metrics label. + pub const fn as_str(self) -> &'static str { + match self { + Self::EthChainId => "eth_chainId", + Self::EthBlockNumber => "eth_blockNumber", + Self::EthGetBlockByNumber => "eth_getBlockByNumber", + Self::EthGetBlockByHash => "eth_getBlockByHash", + Self::EthGetLogs => "eth_getLogs", + Self::EthGetTransactionReceipt => "eth_getTransactionReceipt", + Self::EthSubscribe => "eth_subscribe", + Self::OpSupportedCapabilities => "op_supportedCapabilities", + } + } + + const fn index(self) -> usize { + match self { + Self::EthChainId => 0, + Self::EthBlockNumber => 1, + Self::EthGetBlockByNumber => 2, + Self::EthGetBlockByHash => 3, + Self::EthGetLogs => 4, + Self::EthGetTransactionReceipt => 5, + Self::EthSubscribe => 6, + Self::OpSupportedCapabilities => 7, + } + } +} + +impl fmt::Display for SubscriberRpcMethod { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Mechanism that caused the subscriber to issue a provider request. +/// +/// This is the dimension that matters for an RPC budget: a consumer asks for +/// interests and reads batches, and every request below is a consequence the +/// consumer never named. Attributing by cause is what makes an unexpected bill +/// diagnosable from inside the process. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum SubscriberRpcCause { + /// Resolving the provider's chain identity once, before any record escapes. + ChainIdentity, + /// Installing a live stream source. + StreamSubscription, + /// Qualifying a Flashblocks endpoint: capability probe and paired + /// pending-state provider verification. + FlashblocksSetup, + /// Certifying a sealed canonical head while Flashblocks are active, because + /// a Flashblocks endpoint's `newHeads` may carry partial heads. + CanonicalHeadCertification, + /// Sampling OP Stack pending state on the bounded pre-confirmation cadence. + PendingStateSample, + /// Proving a canonical log's block identity when + /// [`SubscriberConfig::verify_log_block_context`] is set. + LogBlockVerification, + /// Bulk owner catch-up requested through + /// [`AlloySubscriber::reconcile_interest_owners`]. + OwnerReconcile, + /// Draining a queued adoption or continuity backfill. + LazyBackfill, + /// Closing the window missed while a terminated stream was reconnecting. + ReconnectBackfill, + /// Closing the window a live stream dropped: the subscription stayed + /// connected but notifications were lost, so only the missed range is + /// refetched. + GapBackfill, +} + +impl SubscriberRpcCause { + /// Every cause the reactive stack can attribute a request to, in reporting + /// order. + pub const ALL: [Self; 10] = [ + Self::ChainIdentity, + Self::StreamSubscription, + Self::FlashblocksSetup, + Self::CanonicalHeadCertification, + Self::PendingStateSample, + Self::LogBlockVerification, + Self::OwnerReconcile, + Self::LazyBackfill, + Self::ReconnectBackfill, + Self::GapBackfill, + ]; + + /// Number of distinct causes. + pub const COUNT: usize = Self::ALL.len(); + + /// Stable snake_case name, suitable for a metrics label. + pub const fn as_str(self) -> &'static str { + match self { + Self::ChainIdentity => "chain_identity", + Self::StreamSubscription => "stream_subscription", + Self::FlashblocksSetup => "flashblocks_setup", + Self::CanonicalHeadCertification => "canonical_head_certification", + Self::PendingStateSample => "pending_state_sample", + Self::LogBlockVerification => "log_block_verification", + Self::OwnerReconcile => "owner_reconcile", + Self::LazyBackfill => "lazy_backfill", + Self::ReconnectBackfill => "reconnect_backfill", + Self::GapBackfill => "gap_backfill", + } + } + + const fn index(self) -> usize { + match self { + Self::ChainIdentity => 0, + Self::StreamSubscription => 1, + Self::FlashblocksSetup => 2, + Self::CanonicalHeadCertification => 3, + Self::PendingStateSample => 4, + Self::LogBlockVerification => 5, + Self::OwnerReconcile => 6, + Self::LazyBackfill => 7, + Self::ReconnectBackfill => 8, + Self::GapBackfill => 9, + } + } +} + +impl fmt::Display for SubscriberRpcCause { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Every provider request the reactive stack has issued, by method and by the +/// mechanism responsible for it. +/// +/// Counts are **cumulative for the subscriber's lifetime**. They deliberately +/// survive reconnects, stream-topology changes, and delivery-state resets, so a +/// long-running process can report total RPC consumption; call +/// [`AlloySubscriber::reset_rpc_stats`] to measure a bounded window instead. +/// This is the difference from [`FlashblocksRpcMetrics`], which is scoped to one +/// Flashblocks subscriber generation and resets with it. +/// +/// Every request the subscriber makes is counted here, including the ones also +/// tallied by `FlashblocksRpcMetrics` — reading both never requires adding them +/// together. `FlashblocksRpcMetrics` remains the place for outcomes that are not +/// request counts, such as receipts that were unavailable or samples discarded +/// for racing the pending block. +/// +/// ```no_run +/// # use evm_fork_cache::reactive::{AlloySubscriber, SubscriberRpcCause, SubscriberRpcMethod}; +/// # fn report(subscriber: &AlloySubscriber) { +/// let stats = subscriber.rpc_stats(); +/// println!("total provider requests: {}", stats.total()); +/// println!("eth_getLogs: {}", stats.by_method(SubscriberRpcMethod::EthGetLogs)); +/// for (cause, method, requests) in stats.nonzero() { +/// println!("{cause} / {method}: {requests}"); +/// } +/// # } +/// ``` +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubscriberRpcStats { + counts: [[u64; SubscriberRpcMethod::COUNT]; SubscriberRpcCause::COUNT], +} + +impl Default for SubscriberRpcStats { + fn default() -> Self { + Self { + counts: [[0; SubscriberRpcMethod::COUNT]; SubscriberRpcCause::COUNT], + } + } +} + +impl SubscriberRpcStats { + /// Requests issued for one exact cause/method pair. + pub const fn get(&self, cause: SubscriberRpcCause, method: SubscriberRpcMethod) -> u64 { + self.counts[cause.index()][method.index()] + } + + /// Requests issued for one cause, across every method. + pub fn by_cause(&self, cause: SubscriberRpcCause) -> u64 { + self.counts[cause.index()] + .iter() + .fold(0u64, |total, count| total.saturating_add(*count)) + } + + /// Requests issued for one method, across every cause. + pub fn by_method(&self, method: SubscriberRpcMethod) -> u64 { + self.counts + .iter() + .fold(0u64, |total, row| total.saturating_add(row[method.index()])) + } + + /// Every provider request the subscriber has issued. + pub fn total(&self) -> u64 { + SubscriberRpcCause::ALL + .into_iter() + .fold(0u64, |total, cause| { + total.saturating_add(self.by_cause(cause)) + }) + } + + /// Every cause/method pair in reporting order, including zeroes. + pub fn entries( + &self, + ) -> impl Iterator + '_ { + SubscriberRpcCause::ALL.into_iter().flat_map(move |cause| { + SubscriberRpcMethod::ALL + .into_iter() + .map(move |method| (cause, method, self.get(cause, method))) + }) + } + + /// Only the cause/method pairs that actually issued a request — the useful + /// shape for logging or a metrics export. + pub fn nonzero( + &self, + ) -> impl Iterator + '_ { + self.entries().filter(|(_, _, requests)| *requests > 0) + } +} + +/// Interior-mutable counter set behind [`SubscriberRpcStats`]. +/// +/// Shared through an `Arc` because provider work runs off the subscriber's +/// `&mut self`: bulk owner catch-up is driven as an independent future while +/// live events continue to drain, and the free functions it calls record without +/// any subscriber borrow. `Relaxed` ordering is correct for counters whose only +/// consumer is a diagnostic snapshot. +#[derive(Debug)] +pub(crate) struct SubscriberRpcCounters { + counts: [[AtomicU64; SubscriberRpcMethod::COUNT]; SubscriberRpcCause::COUNT], +} + +impl Default for SubscriberRpcCounters { + fn default() -> Self { + Self { + counts: std::array::from_fn(|_| std::array::from_fn(|_| AtomicU64::new(0))), + } + } +} + +/// Why a live subscription lost data without disconnecting. +/// +/// Both cases were previously invisible: `alloy-pubsub`'s typed subscription +/// stream logs a lagged receiver at `debug` and continues, and discards an +/// undecodable notification the same way. A consumer whose contract is +/// *completeness* cannot build on a stream that loses records silently, so these +/// are surfaced and healed instead. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SubscriberStreamGap { + /// The bounded notification channel overflowed and the transport dropped + /// `skipped` notifications before this receiver observed them. + /// + /// This is backpressure, not a transport fault: the subscriber was not + /// draining as fast as the endpoint pushed. Raising + /// [`SubscriberConfig::log_channel_size`] is the direct remedy. + Lagged { + /// Notifications the transport dropped. + skipped: u64, + }, + /// A notification arrived but did not decode into the expected type. + /// + /// Treated as lost data rather than skipped, because a filter's matched set + /// cannot be proven complete while one of its notifications is unreadable. + Undecodable, +} + +impl SubscriberStreamGap { + /// Notifications known to be missing, when the transport reported a count. + pub const fn skipped(self) -> Option { + match self { + Self::Lagged { skipped } => Some(skipped), + Self::Undecodable => None, + } + } + + /// Stable snake_case name, suitable for a metrics label. + pub const fn as_str(self) -> &'static str { + match self { + Self::Lagged { .. } => "lagged", + Self::Undecodable => "undecodable", + } + } +} + +impl fmt::Display for SubscriberStreamGap { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Lagged { skipped } => write!(f, "lagged({skipped})"), + Self::Undecodable => f.write_str("undecodable"), + } + } +} + +/// Notification loss observed on live subscriptions, and what it cost to heal. +/// +/// A non-zero `lagged_notifications` means the subscriber could not keep up with +/// its endpoint. That is recoverable — the missed window is refetched — but each +/// occurrence buys an `eth_getLogs`, so a steadily climbing count is a signal to +/// raise [`SubscriberConfig::log_channel_size`] rather than to keep paying. +/// +/// Counts are cumulative for the subscriber's lifetime, matching +/// [`SubscriberRpcStats`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SubscriberStreamGapStats { + lagged_notifications: u64, + undecodable_notifications: u64, + log_gaps_healed: u64, + header_gaps: u64, + preconfirmation_gaps: u64, +} + +impl SubscriberStreamGapStats { + /// Notifications dropped by the transport because a bounded channel filled. + pub const fn lagged_notifications(self) -> u64 { + self.lagged_notifications + } + + /// Notifications that arrived but could not be decoded. + pub const fn undecodable_notifications(self) -> u64 { + self.undecodable_notifications + } + + /// Canonical log gaps closed by refetching the exact missed range. + /// + /// Each of these issued provider requests attributed to + /// [`SubscriberRpcCause::GapBackfill`]. + pub const fn log_gaps_healed(self) -> u64 { + self.log_gaps_healed + } + + /// Gaps observed on the canonical block-header stream. + /// + /// These are not refetched here: a consumer that walks a replacement + /// header's parent lineage back to retained canonical history recovers the + /// skipped blocks on the next header it receives. The count exists so that + /// self-healing is visible rather than assumed. + pub const fn header_gaps(self) -> u64 { + self.header_gaps + } + + /// Gaps observed on a pre-confirmation log stream, each of which discarded + /// the speculative snapshot rather than publishing an incomplete one. + pub const fn preconfirmation_gaps(self) -> u64 { + self.preconfirmation_gaps + } + + /// Every observed notification loss, across all stream kinds. + pub const fn total_gaps(self) -> u64 { + self.lagged_notifications + .saturating_add(self.undecodable_notifications) + } +} + +/// Interior-mutable counters behind [`SubscriberStreamGapStats`]. +#[derive(Debug, Default)] +pub(crate) struct SubscriberStreamGapCounters { + lagged_notifications: AtomicU64, + undecodable_notifications: AtomicU64, + log_gaps_healed: AtomicU64, + header_gaps: AtomicU64, + preconfirmation_gaps: AtomicU64, +} + +impl SubscriberStreamGapCounters { + fn bump(counter: &AtomicU64, amount: u64) { + counter.fetch_add(amount, Ordering::Relaxed); + } + + #[cfg(feature = "reactive-ws")] + pub(crate) fn record_gap(&self, gap: SubscriberStreamGap) { + match gap { + SubscriberStreamGap::Lagged { skipped } => { + Self::bump(&self.lagged_notifications, skipped.max(1)); + } + SubscriberStreamGap::Undecodable => { + Self::bump(&self.undecodable_notifications, 1); + } + } + } + + pub(crate) fn record_log_gap_healed(&self) { + Self::bump(&self.log_gaps_healed, 1); + } + + pub(crate) fn record_header_gap(&self) { + Self::bump(&self.header_gaps, 1); + } + + pub(crate) fn record_preconfirmation_gap(&self) { + Self::bump(&self.preconfirmation_gaps, 1); + } + + pub(crate) fn snapshot(&self) -> SubscriberStreamGapStats { + let load = |counter: &AtomicU64| counter.load(Ordering::Relaxed); + SubscriberStreamGapStats { + lagged_notifications: load(&self.lagged_notifications), + undecodable_notifications: load(&self.undecodable_notifications), + log_gaps_healed: load(&self.log_gaps_healed), + header_gaps: load(&self.header_gaps), + preconfirmation_gaps: load(&self.preconfirmation_gaps), + } + } + + pub(crate) fn reset(&self) { + for counter in [ + &self.lagged_notifications, + &self.undecodable_notifications, + &self.log_gaps_healed, + &self.header_gaps, + &self.preconfirmation_gaps, + ] { + counter.store(0, Ordering::Relaxed); + } + } +} + +impl SubscriberRpcCounters { + /// Record one issued request. + pub(crate) fn record(&self, cause: SubscriberRpcCause, method: SubscriberRpcMethod) { + self.record_many(cause, method, 1); + } + + /// Record `requests` issued requests, for a batch that ships several calls + /// of one method in a single round trip. + pub(crate) fn record_many( + &self, + cause: SubscriberRpcCause, + method: SubscriberRpcMethod, + requests: u64, + ) { + self.counts[cause.index()][method.index()].fetch_add(requests, Ordering::Relaxed); + } + + /// Snapshot every counter. + pub(crate) fn snapshot(&self) -> SubscriberRpcStats { + SubscriberRpcStats { + counts: std::array::from_fn(|cause| { + std::array::from_fn(|method| self.counts[cause][method].load(Ordering::Relaxed)) + }), + } + } + + /// Zero every counter. + pub(crate) fn reset(&self) { + for row in &self.counts { + for counter in row { + counter.store(0, Ordering::Relaxed); + } + } + } +} + /// Successful initial Flashblocks endpoint preflight. /// /// This proves chain identity and either subscription acknowledgement for @@ -11084,7 +11772,10 @@ enum HandlerRegistrationCatchup { CoordinatedCanonical(BlockRef), } -const DELIVERY_WITNESS_VERSION: u32 = 1; +// 2: `ChainControl` gained `LogCoverage`, so a witness can encode a control +// shape version 1 readers cannot interpret. Bumping keeps a replayed token from +// an older process from being matched against a newer encoding. +const DELIVERY_WITNESS_VERSION: u32 = 2; const DELIVERY_WITNESS_DOMAIN: &[u8] = b"evm-fork-cache/reactive-delivery-witness"; #[derive(serde::Serialize)] @@ -11964,7 +12655,8 @@ where self.runtime.coverage_head, self.runtime.safe_head, self.runtime.finalized_head, - ); + ) + .with_log_coverage_head(self.runtime.log_coverage_head); match validate_canonical_sequence_internal( &state, batch, @@ -12129,6 +12821,7 @@ fn latest_canonical_batch_block( | ChainControl::CanonicalProgress(block) => Some(block), ChainControl::Safe(_) | ChainControl::Finalized(_) + | ChainControl::LogCoverage(_) | ChainControl::Barrier { block: None, .. } => None, }) .max_by_key(|block| block.number) @@ -12555,10 +13248,34 @@ pub struct AlloySubscriber { /// bounded request budget. preconfirmed_unavailable_receipts: HashSet, last_certified_canonical_head: Option, + /// When the canonical head was last certified against the provider. + /// + /// A Flashblocks endpoint replaces the `newHeads` subscription with a + /// fixed-interval certification poll, because its `newHeads` may carry + /// partial heads. Recording the last certification lets the timer suppress + /// itself when the flashblock stream already proved a block sealed, so the + /// poll spends a request only when nothing else did. + last_canonical_head_certification: Option, + /// Set when a `newFlashblocks` payload opens a block, which means the + /// previous block sealed and its canonical head is worth certifying. + sealed_block_pending_certification: bool, + /// Highest canonical block observed while every log source was whole, and + /// the last value attested to the consumer. Advances only through + /// `note_attestable_canonical_block`, which a live gap resets. + attestable_canonical_head: Option, + attested_log_coverage: Option, pending_preconfirmation_invalidation: bool, pending_flashblock_reconnects: FuturesUnordered>, pending_flashblock_reconnect_sources: Vec, flashblocks_rpc_metrics: FlashblocksRpcMetrics, + /// Every provider request this subscriber has issued, by method and cause. + /// Shared so catch-up futures and the free functions they call can record + /// without borrowing the subscriber; see [`SubscriberRpcCounters`]. + rpc_counters: Arc, + /// Notification loss observed on live subscriptions. Shared for the same + /// reason as `rpc_counters`: stream adapters record without a subscriber + /// borrow. + gap_counters: Arc, consecutive_flashblock_poll_failures: usize, flashblock_rpc_request_times: VecDeque, _network: PhantomData, @@ -12593,6 +13310,9 @@ struct SubscriberOwnerCatchupOptions { max_logs: usize, max_log_bytes: usize, max_requests_in_flight: usize, + /// Which mechanism asked for this catch-up, so its provider requests are + /// attributed to the caller rather than to the shared fetch helper. + cause: SubscriberRpcCause, } struct SubscriberOwnerReconcileFilter { @@ -12686,10 +13406,16 @@ impl AlloySubscriber { preconfirmed_receipted_transactions: HashSet::new(), preconfirmed_unavailable_receipts: HashSet::new(), last_certified_canonical_head: None, + last_canonical_head_certification: None, + sealed_block_pending_certification: false, + attestable_canonical_head: None, + attested_log_coverage: None, pending_preconfirmation_invalidation: false, pending_flashblock_reconnects: FuturesUnordered::new(), pending_flashblock_reconnect_sources: Vec::new(), flashblocks_rpc_metrics: FlashblocksRpcMetrics::default(), + rpc_counters: Arc::new(SubscriberRpcCounters::default()), + gap_counters: Arc::new(SubscriberStreamGapCounters::default()), consecutive_flashblock_poll_failures: 0, flashblock_rpc_request_times: VecDeque::new(), _network: PhantomData, @@ -12858,6 +13584,56 @@ impl AlloySubscriber { self.flashblocks_rpc_metrics } + /// Every provider request this subscriber has issued, attributed to the + /// method and the mechanism responsible for it. + /// + /// Cumulative for the subscriber's lifetime: unlike + /// [`flashblocks_rpc_metrics`](Self::flashblocks_rpc_metrics), these counts + /// survive reconnects and delivery-state resets so a long-running process + /// can report total RPC consumption. Use + /// [`reset_rpc_stats`](Self::reset_rpc_stats) to measure a bounded window. + pub fn rpc_stats(&self) -> SubscriberRpcStats { + self.rpc_counters.snapshot() + } + + /// Zero every [`rpc_stats`](Self::rpc_stats) counter, starting a new + /// measurement window. Takes `&self` so a window can be opened while + /// catch-up work holds the subscriber. + pub fn reset_rpc_stats(&self) { + self.rpc_counters.reset(); + } + + /// Notification loss observed on live subscriptions, and what healing it + /// cost. + /// + /// A subscription that never lags reports zeroes here. That is evidence + /// nothing was *lost*, which is necessary before treating the stream as + /// authoritative — but it is not evidence that everything has *arrived*. + /// Deciding a particular block's set is closed needs ordering evidence from + /// the log stream itself; see [`ChainControl::LogCoverage`]. + pub fn stream_gap_stats(&self) -> SubscriberStreamGapStats { + self.gap_counters.snapshot() + } + + /// Zero every [`stream_gap_stats`](Self::stream_gap_stats) counter. + pub fn reset_stream_gap_stats(&self) { + self.gap_counters.reset(); + } + + /// Record one issued provider request against this subscriber's counters. + fn record_rpc(&self, cause: SubscriberRpcCause, method: SubscriberRpcMethod) { + self.rpc_counters.record(cause, method); + } + + /// Bounded notification capacity for a pubsub log stream. + #[cfg(feature = "reactive-ws")] + fn log_channel_size(&self) -> usize { + self.config + .log_channel_size + .unwrap_or(self.config.max_batch_size) + .max(1) + } + /// Registered interests across base and owner-scoped registrations. pub fn registered_interests(&self) -> &[ReactiveInterest] { &self.interests @@ -13980,7 +14756,77 @@ impl AlloySubscriber { .retain(|id, _| live_ids.contains(id)); } + /// Record a canonical header observed while every log source was whole. + /// + /// Called before the header is enqueued, so a gap discovered later in the + /// same poll cannot retroactively attest a block whose logs it may have + /// dropped: `reset_log_attestation` clears the candidate, and it only + /// re-advances once a later header arrives after the gap was healed. + fn note_attestable_canonical_block(&mut self, record: &ReactiveInputRecord) { + if !self.attests_log_coverage() { + return; + } + let Some(block) = record.context.block.as_ref() else { + return; + }; + let advances = self + .attestable_canonical_head + .as_ref() + .is_none_or(|current| block.number > current.number); + if advances { + self.attestable_canonical_head = Some(*block); + } + } + + /// Withdraw the pending attestation candidate after detected loss. + /// + /// The healed window is refetched, but the candidate is still dropped: a + /// consumer must not be told a block was whole on the strength of an + /// observation made before the loss was known. + fn reset_log_attestation(&mut self) { + self.attestable_canonical_head = None; + } + + /// Whether this subscriber can prove the attestation it would emit. + /// + /// Mirrors [`SubscriberCapability::LogCoverageAttestation`]: only the pubsub + /// log streams surface a dropped notification, and only a subscriber with log + /// interests has anything to attest about. + fn attests_log_coverage(&self) -> bool { + matches!( + resolve_subscriber_transport(self.mode), + Ok(SubscriberTransport::PubSub) + ) && self + .interests + .iter() + .any(|interest| matches!(interest, ReactiveInterest::Logs(_))) + } + + /// Queue a `LogCoverage` control when the attested watermark advances. + fn queue_log_coverage_attestation(&mut self) { + if !self.attests_log_coverage() { + return; + } + let Some(candidate) = self.attestable_canonical_head else { + return; + }; + let advances = self + .attested_log_coverage + .as_ref() + .is_none_or(|attested| candidate.number > attested.number); + if !advances { + return; + } + self.attested_log_coverage = Some(candidate); + self.pending_chain_controls + .push_back(ChainControl::LogCoverage(candidate)); + } + fn drain_next_scoped_batch(&mut self) -> Option> { + // Attest before the emptiness check: the watermark may be the only thing + // this batch has to say. Controls still drain only once every record + // ahead of them has left, so an attestation never precedes its header. + self.queue_log_coverage_attestation(); if self.pending_records.is_empty() && self.pending_chain_controls.is_empty() && !self.pending_preconfirmation_invalidation @@ -14059,6 +14905,10 @@ impl AlloySubscriber { self.next_log_source_id = 0; self.sources_dirty = true; self.last_certified_canonical_head = None; + self.last_canonical_head_certification = None; + self.sealed_block_pending_certification = false; + self.attestable_canonical_head = None; + self.attested_log_coverage = None; self.reset_flashblock_tracking(); } @@ -14476,6 +15326,13 @@ enum SubscriberEvent { #[cfg(feature = "raw-flashblocks-json")] ExternalFlashblockUpdate(raw_json_flashblocks::QueuedFlashblockUpdate), StreamTerminated(SubscriberStreamSource), + /// A live stream lost notifications without disconnecting. The subscription + /// is still installed, so only the missed window is recovered rather than + /// the source being reconnected. + StreamGap { + source: SubscriberStreamSource, + gap: SubscriberStreamGap, + }, } enum SubscriberReady { @@ -14634,6 +15491,11 @@ where ]; if transport == SubscriberTransport::PubSub { capabilities.push(SubscriberCapability::BlockHeaders); + // Only the pubsub log streams are consumed through + // `gap_observing_stream`, so only they can prove no loss went + // unhealed. The polling transport's watcher cannot, and must not + // claim it. + capabilities.push(SubscriberCapability::LogCoverageAttestation); } if self.config.preconfirmations != PreconfirmationMode::Disabled && (self.uses_external_flashblock_updates() @@ -14752,6 +15614,10 @@ where .ok_or(SubscriberError::InvalidConfig( "Flashblocks preflight requires a stable provider ref", ))?; + self.record_rpc( + SubscriberRpcCause::FlashblocksSetup, + SubscriberRpcMethod::OpSupportedCapabilities, + ); self.flashblocks_rpc_metrics.capability_requests = self .flashblocks_rpc_metrics .capability_requests @@ -14814,6 +15680,10 @@ where } FlashblocksAdapter::PendingStatePolling => { if let Some(state_provider) = self.flashblocks_state_provider.as_ref() { + self.record_rpc( + SubscriberRpcCause::FlashblocksSetup, + SubscriberRpcMethod::EthChainId, + ); self.flashblocks_rpc_metrics.provider_pair_chain_requests = self .flashblocks_rpc_metrics .provider_pair_chain_requests @@ -15071,6 +15941,10 @@ where .await .map_err(PendingFlashblockPollError::into_subscriber)?; for filter in filters { + self.record_rpc( + SubscriberRpcCause::PendingStateSample, + SubscriberRpcMethod::EthGetLogs, + ); self.flashblocks_rpc_metrics.pending_log_requests = self .flashblocks_rpc_metrics .pending_log_requests @@ -15087,6 +15961,10 @@ where .await .map_err(provider_error)?; } + self.record_rpc( + SubscriberRpcCause::PendingStateSample, + SubscriberRpcMethod::EthGetTransactionReceipt, + ); self.flashblocks_rpc_metrics.pending_receipt_requests = self .flashblocks_rpc_metrics .pending_receipt_requests @@ -15115,6 +15993,10 @@ where ), )); } + self.record_rpc( + SubscriberRpcCause::CanonicalHeadCertification, + SubscriberRpcMethod::EthGetBlockByHash, + ); self.flashblocks_rpc_metrics.canonical_head_requests = self .flashblocks_rpc_metrics .canonical_head_requests @@ -15147,6 +16029,10 @@ where async fn fetch_op_pending_block( &mut self, ) -> Result, PendingFlashblockPollError> { + self.record_rpc( + SubscriberRpcCause::PendingStateSample, + SubscriberRpcMethod::EthGetBlockByNumber, + ); let state_provider = self .flashblocks_state_provider .as_ref() @@ -15171,6 +16057,10 @@ where if let Some(chain_id) = self.chain_id { return Ok(chain_id); } + self.record_rpc( + SubscriberRpcCause::ChainIdentity, + SubscriberRpcMethod::EthChainId, + ); let chain_id = self.provider.get_chain_id().await.map_err(provider_error)?; self.chain_id = Some(chain_id); Ok(chain_id) @@ -15361,7 +16251,9 @@ where max_logs: self.config.max_pending_records, max_log_bytes: self.config.max_backfill_log_bytes, max_requests_in_flight: self.config.max_reconcile_requests_in_flight, + cause: SubscriberRpcCause::OwnerReconcile, }, + Arc::clone(&self.rpc_counters), ); let SubscriberOwnerCatchup { logs, certified } = self.drive_reconcile_fetch(fetch, &target_epochs).await?; @@ -15717,6 +16609,7 @@ where self.config.reconnect.clone(), first_delay, self.config.flashblock_poll_interval, + Arc::clone(&self.rpc_counters), )); } @@ -15772,11 +16665,16 @@ where let to_block = match backfill.end_block() { Some(to_block) => to_block, - None => self - .provider - .get_block_number() - .await - .map_err(provider_error)?, + None => { + self.record_rpc( + SubscriberRpcCause::LazyBackfill, + SubscriberRpcMethod::EthBlockNumber, + ); + self.provider + .get_block_number() + .await + .map_err(provider_error)? + } }; if to_block < backfill.start_block() { // An exclusive post-baseline range can be empty when the @@ -15784,8 +16682,13 @@ where // work only after validating that head and seed the filter at // the proven baseline so reconnect catch-up starts at C + 1. let certified = if let Some(retained) = backfill.retained_anchor() { - let actual = - fetch_provider_block_ref::(&self.provider, retained.number).await?; + let actual = fetch_provider_block_ref::( + &self.provider, + retained.number, + &self.rpc_counters, + SubscriberRpcCause::LazyBackfill, + ) + .await?; if !block_ref_satisfies_expected(&actual, retained) { return Err(SubscriberError::InvalidBackfill(format!( "retained anchor {}:{:?} conflicts with provider block {}:{:?}", @@ -15824,8 +16727,14 @@ where continue; } - let through = fetch_provider_block_ref::(&self.provider, to_block).await?; - let request_filters = + let through = fetch_provider_block_ref::( + &self.provider, + to_block, + &self.rpc_counters, + SubscriberRpcCause::LazyBackfill, + ) + .await?; + let request_filters = merged_lazy_backfill_filters(&filters, backfill.start_block(), through.number); let retained = backfill.retained_anchor().copied().into_iter().collect(); let SubscriberOwnerCatchup { @@ -15841,7 +16750,9 @@ where max_logs: self.config.max_pending_records, max_log_bytes: self.config.max_backfill_log_bytes, max_requests_in_flight: self.config.max_reconcile_requests_in_flight, + cause: SubscriberRpcCause::LazyBackfill, }, + Arc::clone(&self.rpc_counters), ) .await .map_err(lazy_backfill_error)?; @@ -16042,15 +16953,22 @@ where id, filter: filter.clone(), }; - let stream = self + self.record_rpc( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + ); + let subscription = self .provider .subscribe_logs(&filter) - .channel_size(self.config.max_batch_size.max(1)) + .channel_size(self.log_channel_size()) .await - .map_err(provider_error)? - .into_stream() - .map(move |log| SubscriberEvent::Log { source_id: id, log }); - Ok(stream_with_termination(stream, source)) + .map_err(provider_error)?; + Ok(gap_observing_stream( + subscription, + source, + Arc::clone(&self.gap_counters), + move |log| SubscriberEvent::Log { source_id: id, log }, + )) } #[cfg(not(feature = "reactive-ws"))] @@ -16074,19 +16992,26 @@ where filter: filter.clone(), }; let params = base_pending_log_filter(&filter)?; - let stream = self + self.record_rpc( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + ); + let subscription = self .provider .subscribe::<_, Log>(("pendingLogs", params)) - .channel_size(self.config.max_batch_size.max(1)) + .channel_size(self.log_channel_size()) .await - .map_err(provider_error)? - .into_stream() - .map(move |log| SubscriberEvent::BasePendingLogTimed { + .map_err(provider_error)?; + Ok(gap_observing_stream( + subscription, + source, + Arc::clone(&self.gap_counters), + move |log| SubscriberEvent::BasePendingLogTimed { source_id: id, log, timing: FlashblockIngressTiming::new(Instant::now()), - }); - Ok(stream_with_termination(stream, source)) + }, + )) } #[cfg(not(feature = "reactive-ws"))] @@ -16103,6 +17028,10 @@ where ) -> Result>, SubscriberError> { #[cfg(feature = "reactive-ws")] { + self.record_rpc( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + ); let stream = self .provider .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",)) @@ -16170,6 +17099,10 @@ where ) -> Result>, SubscriberError> { #[cfg(feature = "reactive-ws")] { + self.record_rpc( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + ); let stream = self .provider .subscribe_pending_transactions() @@ -16197,17 +17130,21 @@ where ) -> Result>, SubscriberError> { #[cfg(feature = "reactive-ws")] { - let stream = self + self.record_rpc( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + ); + let subscription = self .provider .subscribe_blocks() .channel_size(self.config.max_batch_size.max(1)) .await - .map_err(provider_error)? - .into_stream() - .map(SubscriberEvent::BlockHeader); - Ok(stream_with_termination( - stream, + .map_err(provider_error)?; + Ok(gap_observing_stream( + subscription, SubscriberStreamSource::PubSubBlockHeaders, + Arc::clone(&self.gap_counters), + SubscriberEvent::BlockHeader, )) } @@ -16228,6 +17165,10 @@ where let source = SubscriberStreamSource::PollingLog { filter: filter.clone(), }; + self.record_rpc( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + ); let stream = self .provider .watch_logs(&filter) @@ -16253,6 +17194,10 @@ where ) -> Result>, SubscriberError> { #[cfg(feature = "reactive-polling")] { + self.record_rpc( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + ); let stream = self .provider .watch_pending_transactions() @@ -16387,6 +17332,12 @@ where } self.sources_dirty = false; } + SubscriberEvent::StreamGap { source, gap } => { + if let Some(backfill_event) = self.recover_stream_gap(&source, gap).await? { + self.verify_event_log_blocks(&backfill_event).await?; + return Ok(Some(backfill_event)); + } + } event => { let Some(event) = self.normalize_flashblock_event(event).await? else { continue; @@ -16554,6 +17505,14 @@ where } => { let (flashblock, recover_pending_snapshot) = self.accept_base_flashblock(payload)?; + if std::mem::take(&mut self.sealed_block_pending_certification) + && let Some(header_event) = + self.certify_canonical_head_on_sealed_block().await? + { + // Queued rather than returned: the flashblock event this + // arm is normalizing still has to reach the consumer. + self.enqueue_event(header_event); + } let mut logs = Vec::new(); let mut retained = VecDeque::new(); let mut timing = source_timing; @@ -16621,7 +17580,19 @@ where SubscriberEvent::OpFlashblockTickTimed(timing) => { self.poll_op_pending_flashblock(timing).await } - SubscriberEvent::CanonicalHeadTick => self.fetch_certified_canonical_head().await, + SubscriberEvent::CanonicalHeadTick => { + if self.canonical_head_certification_is_current() { + // The flashblock stream already drove a certification inside + // this window; polling again would buy nothing. + self.flashblocks_rpc_metrics.suppressed_canonical_head_polls = self + .flashblocks_rpc_metrics + .suppressed_canonical_head_polls + .saturating_add(1); + Ok(None) + } else { + self.fetch_certified_canonical_head().await + } + } SubscriberEvent::PreconfirmedLogs { flashblock, logs, @@ -16646,9 +17617,35 @@ where } } + /// Whether a certification already happened inside the current poll window. + fn canonical_head_certification_is_current(&self) -> bool { + self.last_canonical_head_certification + .is_some_and(|at| at.elapsed() < self.config.canonical_head_poll_interval) + } + + /// Certify the sealed canonical head because a new block just started. + /// + /// A `newFlashblocks` payload at index zero opens a block, which means the + /// previous one sealed — the exact moment a certification is worth + /// spending. Driving it from that signal instead of a blind timer costs one + /// request per block rather than one per interval, and detects the head + /// sooner. + async fn certify_canonical_head_on_sealed_block( + &mut self, + ) -> Result>, SubscriberError> { + if !needs_header_block_stream(&self.interests) { + return Ok(None); + } + if self.canonical_head_certification_is_current() { + return Ok(None); + } + self.fetch_certified_canonical_head().await + } + async fn fetch_certified_canonical_head( &mut self, ) -> Result>, SubscriberError> { + self.last_canonical_head_certification = Some(Instant::now()); tokio::time::timeout( self.config.canonical_head_request_timeout, self.fetch_certified_canonical_head_inner(), @@ -16700,6 +17697,10 @@ where self.last_certified_canonical_head = Some(certified); return Ok(Some(SubscriberEvent::BlockHeader(header))); } + self.record_rpc( + SubscriberRpcCause::CanonicalHeadCertification, + SubscriberRpcMethod::EthGetBlockByNumber, + ); self.flashblocks_rpc_metrics.canonical_head_requests = self .flashblocks_rpc_metrics .canonical_head_requests @@ -16751,6 +17752,16 @@ where "indexed newFlashblocks item zero omitted its base header".into(), ) })?; + // A new payload id at index zero opens a block, so the + // previous one just sealed. Certifying on that signal is + // what lets the interval timer stop guessing. + if self + .base_flashblock_header + .as_ref() + .is_none_or(|(known, _)| *known != payload.payload_id) + { + self.sealed_block_pending_certification = true; + } self.base_flashblock_header = Some((payload.payload_id, base)); } @@ -17019,6 +18030,10 @@ where let latest = if samples_pending_range { None } else { + self.record_rpc( + SubscriberRpcCause::CanonicalHeadCertification, + SubscriberRpcMethod::EthBlockNumber, + ); self.flashblocks_rpc_metrics.canonical_head_requests = self .flashblocks_rpc_metrics .canonical_head_requests @@ -17037,6 +18052,10 @@ where let pending_block = if samples_pending_range { self.fetch_op_pending_block().await? } else { + self.record_rpc( + SubscriberRpcCause::PendingStateSample, + SubscriberRpcMethod::EthGetBlockByNumber, + ); self.provider .get_block_by_number(BlockNumberOrTag::Pending) .await @@ -17231,6 +18250,10 @@ where &self.provider }; for filter in self.log_stream_filters() { + self.record_rpc( + SubscriberRpcCause::PendingStateSample, + SubscriberRpcMethod::EthGetLogs, + ); self.flashblocks_rpc_metrics.pending_log_requests = self .flashblocks_rpc_metrics .pending_log_requests @@ -17305,6 +18328,11 @@ where if !reserved { return Ok((Vec::new(), Vec::new(), Vec::new())); } + self.rpc_counters.record_many( + SubscriberRpcCause::PendingStateSample, + SubscriberRpcMethod::EthGetTransactionReceipt, + transaction_hashes.len() as u64, + ); self.flashblocks_rpc_metrics.pending_receipt_requests = self .flashblocks_rpc_metrics .pending_receipt_requests @@ -17543,7 +18571,8 @@ where | SubscriberEvent::PreconfirmedLogs { .. } | SubscriberEvent::FlashblockInvalidated | SubscriberEvent::FlashblockObserved - | SubscriberEvent::StreamTerminated(_) => Ok(()), + | SubscriberEvent::StreamTerminated(_) + | SubscriberEvent::StreamGap { .. } => Ok(()), } } @@ -17569,6 +18598,10 @@ where .log_verification_provider .as_ref() .unwrap_or(&self.provider); + self.rpc_counters.record( + SubscriberRpcCause::LogBlockVerification, + SubscriberRpcMethod::EthGetBlockByNumber, + ); let block = provider .get_block_by_number(BlockNumberOrTag::Number(number)) .await @@ -17644,7 +18677,8 @@ where | SubscriberEvent::PreconfirmedLogs { .. } | SubscriberEvent::FlashblockInvalidated | SubscriberEvent::FlashblockObserved - | SubscriberEvent::StreamTerminated(_) => {} + | SubscriberEvent::StreamTerminated(_) + | SubscriberEvent::StreamGap { .. } => {} } } @@ -17753,6 +18787,7 @@ where SubscriberEvent::BlockHeader(header) => { if needs_header_block_stream(&self.interests) { let record = block_header_input_record::(header); + self.note_attestable_canonical_block(&record); self.enqueue_record_with_excluded_owners(record, excluded); } } @@ -17796,7 +18831,9 @@ where | SubscriberEvent::FlashblockObserved => {} #[cfg(feature = "raw-flashblocks-json")] SubscriberEvent::ExternalFlashblockUpdate(_) => {} - SubscriberEvent::StreamTerminated(_) => {} + // `next_event` intercepts a gap and recovers it before delivery; + // this arm keeps the classification exhaustive. + SubscriberEvent::StreamTerminated(_) | SubscriberEvent::StreamGap { .. } => {} } } @@ -17959,9 +18996,83 @@ where Ok(backfill_event) } + /// Recover from notification loss on a still-connected stream. + /// + /// The policy differs by stream because what a gap costs differs: + /// + /// - **Canonical logs** are authoritative and unrecoverable downstream, so + /// the exact missed range is refetched. This is the only case that spends + /// an RPC, and it spends the minimum: one bounded window per gap. + /// - **Canonical headers** are self-healing at the consumer. A driver that + /// walks a replacement header's parent lineage back to retained canonical + /// history recovers the skipped blocks from the next header it receives, + /// so refetching here would duplicate that work. The gap is counted so the + /// self-healing is visible rather than assumed. + /// - **Pre-confirmation logs** are speculative by construction. A punctured + /// preview must not be published, so the snapshot is discarded and the + /// next complete generation replaces it. + /// + /// # Errors + /// + /// Returns an error when a canonical log gap cannot be bounded because the + /// source has no delivery anchor yet. Silently continuing would mean knowing + /// that logs were lost and doing nothing, which is exactly the failure this + /// machinery exists to eliminate. + async fn recover_stream_gap( + &mut self, + source: &SubscriberStreamSource, + gap: SubscriberStreamGap, + ) -> Result>, SubscriberError> { + match source { + SubscriberStreamSource::PubSubLog { id, .. } => { + self.reset_log_attestation(); + if !self.last_seen_log_blocks.contains_key(id) { + return Err(SubscriberError::Provider(format!( + "Alloy subscriber {} lost notifications ({gap}) before establishing a \ + delivery anchor, so the missed range cannot be bounded", + source.label() + ))); + } + let event = self + .backfill_source_window(source, SubscriberRpcCause::GapBackfill) + .await?; + self.gap_counters.record_log_gap_healed(); + Ok(event) + } + SubscriberStreamSource::PubSubBlockHeaders => { + self.gap_counters.record_header_gap(); + Ok(None) + } + SubscriberStreamSource::BasePendingLog { .. } => { + self.gap_counters.record_preconfirmation_gap(); + self.invalidate_preconfirmation_snapshot(); + Ok(Some(SubscriberEvent::FlashblockInvalidated)) + } + _ => Ok(None), + } + } + async fn backfill_reconnected_source( &mut self, source: &SubscriberStreamSource, + ) -> Result>, SubscriberError> { + self.backfill_source_window(source, SubscriberRpcCause::ReconnectBackfill) + .await + } + + /// Refetch a log source's window from its delivery anchor to the current + /// head. + /// + /// Shared by the two situations that lose a bounded range of canonical logs + /// — a stream that terminated and reconnected, and a stream that stayed + /// connected but dropped notifications. `cause` attributes the requests to + /// whichever of those spent them, so + /// [`rpc_stats`](Self::rpc_stats) can distinguish reconnect churn from + /// backpressure loss. + async fn backfill_source_window( + &mut self, + source: &SubscriberStreamSource, + cause: SubscriberRpcCause, ) -> Result>, SubscriberError> { if source.is_flashblocks() { return Ok(None); @@ -17973,6 +19084,7 @@ where return Ok(None); }; + self.record_rpc(cause, SubscriberRpcMethod::EthBlockNumber); let latest = self .provider .get_block_number() @@ -17982,6 +19094,7 @@ where return Ok(None); } + self.record_rpc(cause, SubscriberRpcMethod::EthGetLogs); let logs = self .provider .get_logs(&filter.clone().from_block(from_block).to_block(latest)) @@ -18346,6 +19459,86 @@ where } } +/// Turn a pubsub subscription into a stream that reports dropped notifications +/// instead of hiding them. +/// +/// [`Subscription::into_stream`] is deliberately not used: it treats both a +/// lagged receiver and an undecodable payload as `continue`, logging at `debug` +/// and moving on, so a consumer cannot distinguish a complete stream from a +/// punctured one. Consuming the raw subscription makes a broadcast `Lagged` a +/// first-class [`SubscriberEvent::StreamGap`], while `Closed` still ends the +/// stream so the existing reconnect path handles a genuine disconnect unchanged. +/// +/// [`Subscription::into_stream`]: alloy_pubsub::Subscription::into_stream +#[cfg(feature = "reactive-ws")] +fn gap_observing_stream( + subscription: alloy_pubsub::Subscription, + source: SubscriberStreamSource, + gap_counters: Arc, + to_event: F, +) -> BoxStream<'static, SubscriberEvent> +where + N: Network + 'static, + T: serde::de::DeserializeOwned + Send + 'static, + F: FnMut(T) -> SubscriberEvent + Send + 'static, +{ + // The decode closure and the receiver both live in the unfold state, so no + // borrow is held across an await point. + struct GapState { + raw: alloy_pubsub::RawSubscription, + to_event: F, + source: SubscriberStreamSource, + gap_counters: Arc, + _item: PhantomData T>, + } + + let state = GapState { + raw: subscription.into_raw(), + to_event, + source: source.clone(), + gap_counters, + _item: PhantomData:: T>, + }; + + let stream = stream::unfold(state, |mut state| async move { + let gap = match state.raw.recv().await { + Ok(value) => match serde_json::from_str::(value.get()) { + Ok(item) => { + let event = (state.to_event)(item); + return Some((event, state)); + } + Err(error) => { + tracing::warn!( + stream = state.source.label(), + error = %error, + "pubsub notification did not decode; treating it as lost data" + ); + SubscriberStreamGap::Undecodable + } + }, + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!( + stream = state.source.label(), + skipped, + "pubsub notification channel overflowed; the missed window will be recovered" + ); + SubscriberStreamGap::Lagged { skipped } + } + // A closed channel is a disconnect, not a gap. Ending the stream + // lets `stream_with_termination` drive the existing reconnect. + Err(broadcast::error::RecvError::Closed) => return None, + }; + state.gap_counters.record_gap(gap); + let event = SubscriberEvent::StreamGap { + source: state.source.clone(), + gap, + }; + Some((event, state)) + }); + + stream_with_termination(stream, source) +} + fn stream_with_termination( stream: S, source: SubscriberStreamSource, @@ -18368,6 +19561,7 @@ fn flashblock_reconnect_future( reconnect: SubscriberReconnectConfig, first_delay: Duration, flashblock_poll_interval: Duration, + counters: Arc, ) -> FlashblockReconnectFuture where N: Network + 'static, @@ -18394,6 +19588,7 @@ where source.clone(), channel_size, flashblock_poll_interval, + counters.as_ref(), ) .await { @@ -18427,12 +19622,13 @@ async fn connect_flashblock_source_once( source: SubscriberStreamSource, channel_size: usize, flashblock_poll_interval: Duration, + counters: &SubscriberRpcCounters, ) -> Result>, SubscriberError> where N: Network + 'static, { #[cfg(not(feature = "reactive-ws"))] - let _ = provider; + let _ = (provider, counters); match source { SubscriberStreamSource::BasePendingLog { id, filter } => { @@ -18443,6 +19639,10 @@ where filter: filter.clone(), }; let params = base_pending_log_filter(&filter)?; + counters.record( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + ); let stream = provider .subscribe::<_, Log>(("pendingLogs", params)) .channel_size(channel_size.max(1)) @@ -18467,6 +19667,10 @@ where SubscriberStreamSource::BaseFlashblocks => { #[cfg(feature = "reactive-ws")] { + counters.record( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + ); let stream = provider .subscribe::<_, BaseFlashblockWirePayload>(("newFlashblocks",)) .channel_size(channel_size.max(1)) @@ -20025,6 +21229,7 @@ mod subscriber_helper_tests { pending_receipts_unavailable: 0, failed_requests: 0, raced_samples: 0, + suppressed_canonical_head_polls: 0, } ); assert!(asserter.read_q().is_empty()); @@ -23782,11 +24987,14 @@ fn validate_backfill_resource_limits( async fn fetch_provider_block_ref( provider: &P, number: u64, + counters: &SubscriberRpcCounters, + cause: SubscriberRpcCause, ) -> Result where P: Provider + Send + Sync, N: Network, { + counters.record(cause, SubscriberRpcMethod::EthGetBlockByNumber); let block = provider .get_block_by_number(BlockNumberOrTag::Number(number)) .await @@ -23954,13 +25162,17 @@ async fn fetch_owner_catchup( retained: Vec, through: BlockRef, options: SubscriberOwnerCatchupOptions, + counters: Arc, ) -> Result where P: Provider + Send + Sync, N: Network, { + let counters = counters.as_ref(); if !options.target_preverified { - let _ = verify_provider_reconcile_target::(&provider, &through).await?; + let _ = + verify_provider_reconcile_target::(&provider, &through, counters, options.cause) + .await?; } let mut certified_positions = HashSet::new(); for position in retained { @@ -23968,7 +25180,13 @@ where || (position.number.checked_add(1) == Some(through.number) && through.parent_hash == Some(position.hash)); if !target_certifies_position && certified_positions.insert(position) { - let _ = verify_provider_reconcile_target::(&provider, &position).await?; + let _ = verify_provider_reconcile_target::( + &provider, + &position, + counters, + options.cause, + ) + .await?; } } let mut logs = Vec::new(); @@ -23976,6 +25194,7 @@ where let requests = stream::iter(filters.into_iter().map(|filter| { let provider = &provider; async move { + counters.record(options.cause, SubscriberRpcMethod::EthGetLogs); let logs = provider .get_logs(&filter.filter) .await @@ -24008,18 +25227,23 @@ where logs.extend(fetched); } validate_owner_backfill_log_set(&logs)?; - let certified = verify_provider_reconcile_target::(&provider, &through).await?; + let certified = + verify_provider_reconcile_target::(&provider, &through, counters, options.cause) + .await?; Ok(SubscriberOwnerCatchup { logs, certified }) } async fn verify_provider_reconcile_target( provider: &P, expected: &BlockRef, + counters: &SubscriberRpcCounters, + cause: SubscriberRpcCause, ) -> Result where P: Provider + Send + Sync, N: Network, { + counters.record(cause, SubscriberRpcMethod::EthGetBlockByNumber); let block = provider .get_block_by_number(BlockNumberOrTag::Number(expected.number)) .await @@ -24204,3 +25428,679 @@ pub enum SubscriberError { #[error("subscriber resource limit exceeded: {0}")] ResourceExhausted(String), } + +/// Canonical head certification is the second unrequested request stream in the +/// live path: on a Flashblocks endpoint it replaces the `newHeads` subscription +/// with a fixed-interval poll, so it bills a request per tick regardless of +/// whether the head actually moved. +#[cfg(test)] +mod canonical_head_rpc_stats_tests { + use super::*; + use alloy_provider::ProviderBuilder; + use alloy_rpc_types_eth::{Block, Header as RpcHeader}; + use alloy_transport::mock::Asserter; + + fn sealed_head() -> Block { + Block::empty(RpcHeader { + hash: B256::repeat_byte(0x65), + inner: alloy_consensus::Header { + number: 101, + parent_hash: B256::repeat_byte(0x64), + timestamp: 1_700_000_101, + ..alloy_consensus::Header::default() + }, + total_difficulty: None, + size: None, + }) + } + + /// Two ticks, one new head: the poll that observes no change still costs a + /// request, and `rpc_stats` reports both. This is the in-process form of the + /// measured Base head-poll volume — the counter tracks requests issued, not + /// events produced. + #[tokio::test] + async fn unchanged_head_still_counts_its_certification_request() { + let asserter = Asserter::new(); + asserter.push_success(&Some(sealed_head())); + asserter.push_success(&Some(sealed_head())); + let provider = ProviderBuilder::new().connect_mocked_client(asserter.clone()); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ); + subscriber.chain_id = Some(8_453); + + let first = subscriber + .fetch_certified_canonical_head() + .await + .expect("first certification succeeds"); + assert!( + matches!(first, Some(SubscriberEvent::BlockHeader(_))), + "a newly certified head is delivered" + ); + + let second = subscriber + .fetch_certified_canonical_head() + .await + .expect("second certification succeeds"); + assert!( + second.is_none(), + "an unchanged head produces no event to deliver" + ); + + let stats = subscriber.rpc_stats(); + assert_eq!( + stats.get( + SubscriberRpcCause::CanonicalHeadCertification, + SubscriberRpcMethod::EthGetBlockByNumber, + ), + 2, + "both polls are billed even though only one advanced the head" + ); + assert_eq!(stats.total(), 2, "certification is the only request issued"); + assert_eq!( + subscriber + .flashblocks_rpc_metrics() + .canonical_head_requests(), + 2, + "the attributed counter agrees with the Flashblocks-scoped one" + ); + assert!(asserter.read_q().is_empty()); + } +} + +/// Notification loss on a live subscription must be observable and recoverable. +/// +/// The premise of sourcing canonical logs from a subscription is that loss can be +/// detected; `alloy-pubsub`'s typed stream defeats that by treating a lagged +/// receiver and an undecodable payload as `continue`. These tests drive a real +/// broadcast channel — overflowing it for real rather than simulating the error — +/// and pin both the detection and the bounded recovery it triggers. +#[cfg(all(test, feature = "reactive-ws"))] +mod stream_gap_tests { + use super::*; + use alloy_provider::ProviderBuilder; + use alloy_transport::mock::Asserter; + use serde_json::value::RawValue; + + fn raw_json(value: &serde_json::Value) -> Box { + RawValue::from_string(value.to_string()).expect("valid JSON") + } + + fn wire_log(block_number: u64, log_index: u64) -> Box { + raw_json(&serde_json::json!({ + "address": "0x0000000000000000000000000000000000000077", + "topics": ["0x1111111111111111111111111111111111111111111111111111111111111111"], + "data": "0x", + "blockHash": format!("0x{:064x}", block_number), + "blockNumber": format!("0x{block_number:x}"), + "transactionHash": format!("0x{:064x}", 0x20 + log_index), + "transactionIndex": format!("0x{log_index:x}"), + "logIndex": format!("0x{log_index:x}"), + "removed": false, + })) + } + + fn log_source(id: usize) -> SubscriberStreamSource { + SubscriberStreamSource::PubSubLog { + id, + filter: Filter::new().address(Address::repeat_byte(0x77)), + } + } + + /// Build a `Subscription` over a real broadcast channel so the test can + /// overflow it, feed it garbage, or close it. + fn wired_subscription( + capacity: usize, + ) -> ( + tokio::sync::broadcast::Sender>, + alloy_pubsub::Subscription, + ) { + let (tx, rx) = tokio::sync::broadcast::channel(capacity); + let raw = alloy_pubsub::RawSubscription { + rx, + local_id: B256::repeat_byte(0x5b), + }; + (tx, raw.into_typed()) + } + + fn gap_stream( + subscription: alloy_pubsub::Subscription, + source: SubscriberStreamSource, + counters: Arc, + ) -> BoxStream<'static, SubscriberEvent> { + gap_observing_stream(subscription, source, counters, |log| SubscriberEvent::Log { + source_id: 0, + log, + }) + } + + /// A channel that overflows reports the loss. Under + /// `Subscription::into_stream` this same sequence yields only the surviving + /// notification, with the drop visible nowhere. + #[tokio::test] + async fn overflowing_channel_reports_the_gap_instead_of_skipping_it() { + let counters = Arc::new(SubscriberStreamGapCounters::default()); + let (tx, subscription) = wired_subscription(2); + // Publish past capacity before the stream is ever polled. + for index in 0..5 { + tx.send(wire_log(100 + index, index)) + .expect("receiver alive"); + } + let mut stream = gap_stream(subscription, log_source(0), Arc::clone(&counters)); + + let first = stream.next().await.expect("an event is produced"); + let SubscriberEvent::StreamGap { source, gap } = first else { + panic!("expected the dropped notifications to surface as a gap, got a delivery"); + }; + assert!( + matches!(source, SubscriberStreamSource::PubSubLog { id: 0, .. }), + "the gap must name the source that lost data" + ); + assert_eq!(gap, SubscriberStreamGap::Lagged { skipped: 3 }); + assert_eq!(gap.skipped(), Some(3)); + + // The surviving notifications still arrive after the gap is reported. + assert!(matches!( + stream.next().await, + Some(SubscriberEvent::Log { .. }) + )); + assert_eq!(counters.snapshot().lagged_notifications(), 3); + assert_eq!(counters.snapshot().undecodable_notifications(), 0); + } + + /// An unreadable payload is lost data, not a skippable curiosity: a filter's + /// matched set cannot be called complete while one notification is opaque. + #[tokio::test] + async fn undecodable_notification_fails_closed_as_a_gap() { + let counters = Arc::new(SubscriberStreamGapCounters::default()); + let (tx, subscription) = wired_subscription(8); + tx.send(raw_json(&serde_json::json!({"not": "a log"}))) + .expect("receiver alive"); + tx.send(wire_log(101, 0)).expect("receiver alive"); + let mut stream = gap_stream(subscription, log_source(0), Arc::clone(&counters)); + + assert_eq!( + stream.next().await.map(|event| matches!( + event, + SubscriberEvent::StreamGap { + gap: SubscriberStreamGap::Undecodable, + .. + } + )), + Some(true), + ); + assert!(matches!( + stream.next().await, + Some(SubscriberEvent::Log { .. }) + )); + let stats = counters.snapshot(); + assert_eq!(stats.undecodable_notifications(), 1); + assert_eq!(stats.lagged_notifications(), 0); + assert_eq!(stats.total_gaps(), 1); + } + + /// A closed channel is a disconnect, not a gap: it must still end the stream + /// so the existing reconnect path runs unchanged. + #[tokio::test] + async fn closed_channel_terminates_the_stream_for_reconnect() { + let counters = Arc::new(SubscriberStreamGapCounters::default()); + let (tx, subscription) = wired_subscription(8); + tx.send(wire_log(101, 0)).expect("receiver alive"); + drop(tx); + let mut stream = gap_stream(subscription, log_source(0), Arc::clone(&counters)); + + assert!(matches!( + stream.next().await, + Some(SubscriberEvent::Log { .. }) + )); + assert!( + matches!( + stream.next().await, + Some(SubscriberEvent::StreamTerminated( + SubscriberStreamSource::PubSubLog { id: 0, .. } + )) + ), + "a closed subscription must terminate, not report a gap" + ); + assert_eq!(counters.snapshot().total_gaps(), 0); + } + + fn mocked_subscriber( + asserter: Asserter, + ) -> AlloySubscriber + Clone, Ethereum> { + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + AlloySubscriber::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ) + } + + /// A canonical log gap refetches the source's window from its delivery + /// anchor to the current head, and charges the requests to `GapBackfill` so + /// backpressure loss is distinguishable from reconnect churn. + #[tokio::test] + async fn log_gap_refetches_the_missed_window_and_attributes_it() { + let asserter = Asserter::new(); + asserter.push_success(&U256::from(104)); // eth_blockNumber + asserter.push_success(&vec![ + serde_json::from_str::(wire_log(103, 0).get()).expect("log"), + ]); + let mut subscriber = mocked_subscriber(asserter.clone()); + subscriber.chain_id = Some(1); + // The source has delivered through block 102. + subscriber.last_seen_log_blocks.insert(0, 102); + + let event = subscriber + .recover_stream_gap(&log_source(0), SubscriberStreamGap::Lagged { skipped: 2 }) + .await + .expect("a bounded window is recoverable"); + + assert!( + matches!( + event, + Some(SubscriberEvent::BackfilledLogs { source_id: 0, ref logs }) if logs.len() == 1 + ), + "the missed window is delivered as backfill" + ); + let stats = subscriber.rpc_stats(); + assert_eq!( + stats.by_cause(SubscriberRpcCause::GapBackfill), + 2, + "one head read plus one bounded eth_getLogs" + ); + assert_eq!( + stats.by_cause(SubscriberRpcCause::ReconnectBackfill), + 0, + "a live-stream gap is not reconnect churn" + ); + assert_eq!(subscriber.stream_gap_stats().log_gaps_healed(), 1); + assert!(asserter.read_q().is_empty()); + } + + /// Without a delivery anchor the missed range has no lower bound. Continuing + /// would mean knowing logs were lost and doing nothing, so this fails closed. + #[tokio::test] + async fn log_gap_without_a_delivery_anchor_fails_closed() { + let mut subscriber = mocked_subscriber(Asserter::new()); + subscriber.chain_id = Some(1); + + let Err(error) = subscriber + .recover_stream_gap(&log_source(0), SubscriberStreamGap::Lagged { skipped: 9 }) + .await + else { + panic!("an unbounded gap must not be silently ignored"); + }; + + assert!( + matches!(&error, SubscriberError::Provider(message) + if message.contains("delivery anchor") && message.contains("lagged(9)")), + "the error must name the cause and the loss: {error}" + ); + assert_eq!(subscriber.stream_gap_stats().log_gaps_healed(), 0); + } + + /// Header gaps are recovered by the consumer's parent-lineage walk, so they + /// are counted rather than refetched — no provider request is spent. + #[tokio::test] + async fn header_gap_is_counted_without_spending_a_request() { + let asserter = Asserter::new(); + let mut subscriber = mocked_subscriber(asserter.clone()); + subscriber.chain_id = Some(1); + + let event = subscriber + .recover_stream_gap( + &SubscriberStreamSource::PubSubBlockHeaders, + SubscriberStreamGap::Lagged { skipped: 1 }, + ) + .await + .expect("a header gap is not fatal"); + + assert!(event.is_none(), "no synthetic header is fabricated"); + assert_eq!(subscriber.stream_gap_stats().header_gaps(), 1); + assert_eq!( + subscriber.rpc_stats().total(), + 0, + "the lineage walk already covers this; refetching would duplicate it" + ); + assert!(asserter.read_q().is_empty()); + } + + /// A punctured pre-confirmation must never be published: the speculative + /// snapshot is discarded and the next complete generation replaces it. + #[tokio::test] + async fn preconfirmation_gap_discards_the_speculative_snapshot() { + let mut subscriber = mocked_subscriber(Asserter::new()); + subscriber.chain_id = Some(8_453); + + let event = subscriber + .recover_stream_gap( + &SubscriberStreamSource::BasePendingLog { + id: 0, + filter: Filter::new(), + }, + SubscriberStreamGap::Undecodable, + ) + .await + .expect("a preview gap is recoverable by discarding it"); + + assert!( + matches!(event, Some(SubscriberEvent::FlashblockInvalidated)), + "the incomplete preview must be invalidated, not delivered" + ); + assert_eq!(subscriber.stream_gap_stats().preconfirmation_gaps(), 1); + assert_eq!(subscriber.rpc_stats().total(), 0); + } + + /// Gap counters are diagnostics and must not perturb the delivery path. + #[tokio::test] + async fn resetting_gap_stats_opens_a_new_window() { + let counters = Arc::new(SubscriberStreamGapCounters::default()); + counters.record_gap(SubscriberStreamGap::Lagged { skipped: 4 }); + counters.record_gap(SubscriberStreamGap::Undecodable); + counters.record_header_gap(); + assert_eq!(counters.snapshot().total_gaps(), 5); + + counters.reset(); + + assert_eq!(counters.snapshot(), SubscriberStreamGapStats::default()); + } + + /// Labels are part of the diagnostic contract for a metrics export. + #[test] + fn gap_labels_are_stable() { + assert_eq!( + SubscriberStreamGap::Lagged { skipped: 7 }.as_str(), + "lagged" + ); + assert_eq!(SubscriberStreamGap::Undecodable.as_str(), "undecodable"); + assert_eq!( + SubscriberStreamGap::Lagged { skipped: 7 }.to_string(), + "lagged(7)" + ); + assert_eq!(SubscriberRpcCause::GapBackfill.as_str(), "gap_backfill"); + assert_eq!(SubscriberStreamGap::Undecodable.skipped(), None); + } +} + +/// The attestation must never outrun what the subscriber actually observed +/// whole. These tests drive the watermark directly, because the interesting +/// cases are the ones where it must *refuse* to advance. +#[cfg(all(test, feature = "reactive-ws"))] +mod log_coverage_attestation_tests { + use super::*; + use alloy_provider::ProviderBuilder; + use alloy_rpc_types_eth::Header as RpcHeader; + use alloy_transport::mock::Asserter; + + fn header_record(number: u64) -> ReactiveInputRecord { + block_header_input_record::(RpcHeader { + hash: B256::repeat_byte(number as u8), + inner: alloy_consensus::Header { + number, + parent_hash: B256::repeat_byte((number - 1) as u8), + timestamp: 1_700_000_000 + number, + ..alloy_consensus::Header::default() + }, + total_difficulty: None, + size: None, + }) + } + + fn subscriber( + mode: SubscriberMode, + with_log_interest: bool, + ) -> AlloySubscriber + Clone, Ethereum> { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let mut subscriber = AlloySubscriber::new(provider, mode, SubscriberConfig::default()); + if with_log_interest { + subscriber.interests = vec![ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(Address::repeat_byte(0x77)), + local_matcher: None, + route_key: None, + })]; + } + subscriber + } + + fn queued_coverage( + subscriber: &mut AlloySubscriber + Clone, Ethereum>, + ) -> Vec { + subscriber.queue_log_coverage_attestation(); + subscriber + .pending_chain_controls + .drain(..) + .filter_map(|control| match control { + ChainControl::LogCoverage(block) => Some(block.number), + _ => None, + }) + .collect() + } + + #[test] + fn attestation_advances_with_observed_canonical_headers() { + let mut subscriber = subscriber(SubscriberMode::PubSub, true); + + subscriber.note_attestable_canonical_block(&header_record(101)); + assert_eq!(queued_coverage(&mut subscriber), vec![101]); + + // Re-attesting the same block says nothing new and must not be emitted. + assert!(queued_coverage(&mut subscriber).is_empty()); + + subscriber.note_attestable_canonical_block(&header_record(102)); + assert_eq!(queued_coverage(&mut subscriber), vec![102]); + } + + /// The safety property: a gap discovered after a header was observed must + /// withdraw that header's candidacy. Attesting it would tell the consumer a + /// block was whole on the strength of an observation made before the loss + /// was known. + #[test] + fn a_detected_gap_withdraws_the_pending_attestation() { + let mut subscriber = subscriber(SubscriberMode::PubSub, true); + subscriber.note_attestable_canonical_block(&header_record(101)); + + subscriber.reset_log_attestation(); + + assert!( + queued_coverage(&mut subscriber).is_empty(), + "a withdrawn candidate must not be attested" + ); + + // Only a header observed after the gap re-establishes the watermark. + subscriber.note_attestable_canonical_block(&header_record(102)); + assert_eq!(queued_coverage(&mut subscriber), vec![102]); + } + + /// A withdrawn candidate must not let a *lower* block be attested later + /// either — the watermark is monotonic at the source, not just downstream. + #[test] + fn attestation_never_regresses_after_a_gap() { + let mut subscriber = subscriber(SubscriberMode::PubSub, true); + subscriber.note_attestable_canonical_block(&header_record(105)); + assert_eq!(queued_coverage(&mut subscriber), vec![105]); + + subscriber.reset_log_attestation(); + subscriber.note_attestable_canonical_block(&header_record(103)); + + assert!( + queued_coverage(&mut subscriber).is_empty(), + "an older block must never be attested after a newer one" + ); + } + + /// The polling transport's watcher cannot observe a dropped notification, so + /// it must neither claim the capability nor emit the control. + #[test] + #[cfg(feature = "reactive-polling")] + fn polling_transport_neither_claims_nor_emits_the_attestation() { + let mut subscriber = subscriber(SubscriberMode::Polling, true); + assert!(!subscriber.attests_log_coverage()); + + subscriber.note_attestable_canonical_block(&header_record(101)); + + assert!(queued_coverage(&mut subscriber).is_empty()); + assert!( + !EventSubscriber::capabilities(&subscriber) + .supports(SubscriberCapability::LogCoverageAttestation) + ); + } + + #[test] + fn pubsub_transport_claims_the_capability() { + let subscriber = subscriber(SubscriberMode::PubSub, true); + assert!( + EventSubscriber::capabilities(&subscriber) + .supports(SubscriberCapability::LogCoverageAttestation) + ); + } + + /// Nothing to attest about without log interests, so stay silent rather than + /// emit a vacuously true watermark a consumer might lean on. + #[test] + fn subscriber_without_log_interests_stays_silent() { + let mut subscriber = subscriber(SubscriberMode::PubSub, false); + assert!(!subscriber.attests_log_coverage()); + + subscriber.note_attestable_canonical_block(&header_record(101)); + + assert!(queued_coverage(&mut subscriber).is_empty()); + } +} + +/// The canonical head poll exists because a Flashblocks endpoint's `newHeads` +/// may carry partial heads — but a fixed interval spends a request whether or +/// not anything sealed. These tests pin that a certification driven by the +/// flashblock stream suppresses the redundant tick, and that the timer still +/// works unaided when no such signal exists. +#[cfg(all(test, feature = "reactive-ws"))] +mod canonical_head_suppression_tests { + use super::*; + use alloy_provider::ProviderBuilder; + use alloy_rpc_types_eth::{Block, Header as RpcHeader}; + use alloy_transport::mock::Asserter; + + fn sealed_head(number: u64) -> Block { + Block::empty(RpcHeader { + hash: B256::repeat_byte(number as u8), + inner: alloy_consensus::Header { + number, + parent_hash: B256::repeat_byte((number - 1) as u8), + timestamp: 1_700_000_000 + number, + ..alloy_consensus::Header::default() + }, + total_difficulty: None, + size: None, + }) + } + + fn subscriber( + asserter: Asserter, + ) -> AlloySubscriber + Clone, Ethereum> { + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + let mut subscriber = AlloySubscriber::<_, Ethereum>::new( + provider, + SubscriberMode::PubSub, + SubscriberConfig::default(), + ); + subscriber.chain_id = Some(8_453); + subscriber.interests = vec![ReactiveInterest::Blocks(BlockInterest::default())]; + subscriber + } + + /// A tick inside the window after a certification issues no request. This is + /// the whole saving: on a chain whose blocks seal faster than the interval, + /// most ticks cost nothing. + #[tokio::test] + async fn a_tick_inside_the_window_after_a_certification_costs_nothing() { + let asserter = Asserter::new(); + asserter.push_success(&Some(sealed_head(101))); + let mut subscriber = subscriber(asserter.clone()); + + // One real certification, as the flashblock stream would drive. + let certified = subscriber + .certify_canonical_head_on_sealed_block() + .await + .expect("certification succeeds"); + assert!(matches!(certified, Some(SubscriberEvent::BlockHeader(_)))); + + // A tick immediately afterwards is inside the poll window. + assert!(subscriber.canonical_head_certification_is_current()); + let suppressed = subscriber + .certify_canonical_head_on_sealed_block() + .await + .expect("a suppressed certification is not an error"); + + assert!(suppressed.is_none()); + assert_eq!( + subscriber.rpc_stats().get( + SubscriberRpcCause::CanonicalHeadCertification, + SubscriberRpcMethod::EthGetBlockByNumber, + ), + 1, + "only the first certification may spend a request" + ); + assert!( + asserter.read_q().is_empty(), + "the suppressed call must not consume a queued response" + ); + } + + /// Once the window lapses the certification runs again, so a stalled + /// flashblock stream degrades to the previous polling behaviour rather than + /// to silence. + #[tokio::test] + async fn certification_resumes_once_the_window_lapses() { + let asserter = Asserter::new(); + asserter.push_success(&Some(sealed_head(101))); + asserter.push_success(&Some(sealed_head(102))); + let mut subscriber = subscriber(asserter.clone()); + + let _ = subscriber + .certify_canonical_head_on_sealed_block() + .await + .expect("first certification"); + + // Age the record past the poll interval. + subscriber.last_canonical_head_certification = Some( + Instant::now() + - subscriber.config.canonical_head_poll_interval + - Duration::from_millis(1), + ); + assert!(!subscriber.canonical_head_certification_is_current()); + + let second = subscriber + .certify_canonical_head_on_sealed_block() + .await + .expect("second certification"); + + assert!(matches!(second, Some(SubscriberEvent::BlockHeader(_)))); + assert_eq!( + subscriber + .rpc_stats() + .by_cause(SubscriberRpcCause::CanonicalHeadCertification), + 2 + ); + assert!(asserter.read_q().is_empty()); + } + + /// A subscriber with no block-header interest has no head to certify, so the + /// signal must not manufacture a request. + #[tokio::test] + async fn without_a_header_interest_nothing_is_certified() { + let asserter = Asserter::new(); + let mut subscriber = subscriber(asserter.clone()); + subscriber.interests = Vec::new(); + + let certified = subscriber + .certify_canonical_head_on_sealed_block() + .await + .expect("no interest is not an error"); + + assert!(certified.is_none()); + assert_eq!(subscriber.rpc_stats().total(), 0); + assert!(asserter.read_q().is_empty()); + } +} diff --git a/tests/durable_checkpoint.rs b/tests/durable_checkpoint.rs index bb11f98..83e3f33 100644 --- a/tests/durable_checkpoint.rs +++ b/tests/durable_checkpoint.rs @@ -89,6 +89,7 @@ struct EncodedRuntimeCheckpoint { health: CacheHealth, pending_resyncs: Vec, coverage_head: Option, + log_coverage_head: Option, journal: Vec, freshness: Option, tracking: HashMap, @@ -120,12 +121,13 @@ fn runtime_metadata_with_journal( journal: impl IntoIterator, ) -> DurableCheckpointMetadata { let checkpoint = EncodedRuntimeCheckpoint { - version: 3, + version: 4, safe_head: None, finalized_head: None, health: CacheHealth::Healthy, pending_resyncs: Vec::new(), coverage_head: Some(coverage), + log_coverage_head: None, journal: journal .into_iter() .map(|block| EncodedBlockJournal { @@ -1413,12 +1415,13 @@ fn checkpoint_resume_rejects_semantically_invalid_runtime_state_before_mutation( timestamp: Some(1_700_000_121), }; let malformed = EncodedRuntimeCheckpoint { - version: 3, + version: 4, safe_head: Some(block_120), finalized_head: Some(block_121), health: CacheHealth::Healthy, pending_resyncs: Vec::new(), coverage_head: Some(block_121), + log_coverage_head: None, journal: vec![ EncodedBlockJournal { block: block_121, @@ -1579,12 +1582,13 @@ fn checkpoint_resume_rejects_finality_ahead_of_canonical_coverage() { timestamp: Some(1_700_000_122), }; let malformed = EncodedRuntimeCheckpoint { - version: 3, + version: 4, safe_head: Some(future_safe), finalized_head: None, health: CacheHealth::Healthy, pending_resyncs: Vec::new(), coverage_head: Some(coverage), + log_coverage_head: None, journal: Vec::new(), freshness: None, tracking: HashMap::new(), diff --git a/tests/raw_json_flashblocks.rs b/tests/raw_json_flashblocks.rs index 3c4ea59..1871889 100644 --- a/tests/raw_json_flashblocks.rs +++ b/tests/raw_json_flashblocks.rs @@ -15,7 +15,7 @@ use evm_fork_cache::reactive::{ }; #[cfg(feature = "reactive-ws")] use evm_fork_cache::reactive::{ - ChainStatus, EventSubscriber, LogInterest, ReactiveInput, ReactiveInterest, + ChainStatus, EventSubscriber, LogInterest, ReactiveInput, ReactiveInterest, SubscriberRpcCause, }; use proptest::prelude::*; @@ -1420,6 +1420,15 @@ async fn standardized_updates_enter_the_existing_preconfirmation_pipeline() { if flashblock.provider == ProviderRef::new("raw-json", 7) )); assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0); + // Cross-check the attributed counters against the Flashblocks-scoped ones: + // an application-managed source must issue no provider requests of its own. + for cause in [ + SubscriberRpcCause::FlashblocksSetup, + SubscriberRpcCause::CanonicalHeadCertification, + SubscriberRpcCause::PendingStateSample, + ] { + assert_eq!(subscriber.rpc_stats().by_cause(cause), 0); + } let reset = adapter .reset(ProviderRef::new("raw-json", 8)) diff --git a/tests/raw_json_flashblocks_runtime.rs b/tests/raw_json_flashblocks_runtime.rs index e8f6da3..6834440 100644 --- a/tests/raw_json_flashblocks_runtime.rs +++ b/tests/raw_json_flashblocks_runtime.rs @@ -29,7 +29,7 @@ use evm_fork_cache::reactive::{ HandlerOutcome, InputSource, LogInterest, PreconfirmationMode, ProviderRef, RawJsonFlashblocksAdapter, ReactiveConfig, ReactiveContext, ReactiveEffect, ReactiveHandler, ReactiveInput, ReactiveInputBatch, ReactiveInputRecord, ReactiveInterest, ReactiveRuntime, - StateEffectQuality, SubscriberBackfill, SubscriberConfig, SubscriberMode, + StateEffectQuality, SubscriberBackfill, SubscriberConfig, SubscriberMode, SubscriberRpcCause, }; const POOL_SLOT: u64 = 0; @@ -289,6 +289,15 @@ async fn raw_preview_invalidation_replacement_and_canonical_reconciliation_are_o ); assert!(runtime.active_preconfirmation().is_some()); assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0); + // Cross-check the attributed counters against the Flashblocks-scoped ones: + // an application-managed source must issue no provider requests of its own. + for cause in [ + SubscriberRpcCause::FlashblocksSetup, + SubscriberRpcCause::CanonicalHeadCertification, + SubscriberRpcCause::PendingStateSample, + ] { + assert_eq!(subscriber.rpc_stats().by_cause(cause), 0); + } assert!(cache_asserter.read_q().is_empty()); let reset = adapter @@ -335,6 +344,15 @@ async fn raw_preview_invalidation_replacement_and_canonical_reconciliation_are_o ); assert!(runtime.active_preconfirmation().is_none()); assert_eq!(subscriber.flashblocks_rpc_metrics().total_requests(), 0); + // Cross-check the attributed counters against the Flashblocks-scoped ones: + // an application-managed source must issue no provider requests of its own. + for cause in [ + SubscriberRpcCause::FlashblocksSetup, + SubscriberRpcCause::CanonicalHeadCertification, + SubscriberRpcCause::PendingStateSample, + ] { + assert_eq!(subscriber.rpc_stats().by_cause(cause), 0); + } assert!(cache_asserter.read_q().is_empty()); assert!(subscriber_asserter.read_q().is_empty()); diff --git a/tests/reactive_log_coverage.rs b/tests/reactive_log_coverage.rs new file mode 100644 index 0000000..6f26d0d --- /dev/null +++ b/tests/reactive_log_coverage.rs @@ -0,0 +1,250 @@ +//! Provider-neutral validation of the log-coverage attestation. +//! +//! [`ChainControl::LogCoverage`] is a *negative* guarantee — no log-notification +//! loss went unhealed at or below the named block — and its value depends +//! entirely on being unforgeable. A consumer that trusts it stops re-fetching +//! logs, so every way the watermark could overstate what a source proved has to +//! be rejected: it must not regress, must not outrun canonical coverage, must +//! name a block whose identity agrees with retained history, and must never be +//! inferred from silence. +#![cfg(feature = "reactive")] + +mod common; + +use alloy_network::Ethereum; +use alloy_primitives::B256; +use anyhow::Result; +use common::setup_cache; +use evm_fork_cache::reactive::{ + BlockRef, CanonicalSequenceMutation, CanonicalSequenceState, ChainControl, ChainStatus, + InputSource, ReactiveConfig, ReactiveContext, ReactiveError, ReactiveInput, ReactiveInputBatch, + ReactiveInputRecord, ReactiveRuntime, validate_canonical_sequence, + validate_canonical_sequence_diagnostic, +}; + +fn block(number: u64, hash: B256, parent_hash: B256) -> BlockRef { + BlockRef { + number, + hash, + parent_hash: Some(parent_hash), + timestamp: Some(1_700_000_000 + number), + } +} + +/// Canonical history through block 3, with no attestation yet. +fn state() -> (CanonicalSequenceState, BlockRef, BlockRef) { + let parent = block(2, B256::repeat_byte(0x02), B256::repeat_byte(0x01)); + let head = block(3, B256::repeat_byte(0x03), B256::repeat_byte(0x02)); + ( + CanonicalSequenceState::new(vec![parent, head], Some(head), None, None), + parent, + head, + ) +} + +fn controls(controls: impl IntoIterator) -> ReactiveInputBatch { + ReactiveInputBatch::::new(Vec::new()).with_chain_controls(controls) +} + +/// An absent watermark is *unknown*, never "complete at genesis". A consumer +/// that read `None` as an attestation would stop re-fetching on the strength of +/// a promise nobody made. +#[test] +fn coverage_defaults_to_unknown_and_is_only_ever_set_explicitly() { + let (state, _, head) = state(); + assert_eq!(state.log_coverage_head(), None); + + let seeded = state.clone().with_log_coverage_head(Some(head)); + assert_eq!(seeded.log_coverage_head(), Some(&head)); + + // Seeding is total: an explicit `None` clears rather than preserves. + assert_eq!( + seeded.with_log_coverage_head(None).log_coverage_head(), + None + ); +} + +/// The attestation advances its own watermark and stages a replayable mutation, +/// while making no claim about chain progress: canonical coverage is untouched. +#[test] +fn attestation_advances_only_the_log_watermark() { + let (state, _, head) = state(); + + let validation = + validate_canonical_sequence(&state, &controls([ChainControl::LogCoverage(head)])) + .expect("attesting the canonical head is valid"); + + assert_eq!(validation.next_state().log_coverage_head(), Some(&head)); + assert_eq!( + validation.next_state().coverage_head(), + Some(&head), + "canonical coverage must be unchanged by an attestation" + ); + assert!( + validation.mutations().iter().any( + |mutation| matches!(mutation, CanonicalSequenceMutation::LogCoverage(b) if *b == head) + ), + "the watermark must be replayable by an external checkpoint owner" + ); + assert!( + !validation + .mutations() + .iter() + .any(|mutation| matches!(mutation, CanonicalSequenceMutation::Canonical(_))), + "an attestation must not stage canonical progress" + ); +} + +/// Re-sending the same watermark is harmless; retreating is not. A consumer that +/// had already narrowed a window must never see it widen. +#[test] +fn attestation_may_repeat_but_never_retreat() { + let (state, parent, head) = state(); + let attested = state.with_log_coverage_head(Some(head)); + + validate_canonical_sequence(&attested, &controls([ChainControl::LogCoverage(head)])) + .expect("re-attesting the same block is idempotent"); + + let error = validate_canonical_sequence_diagnostic( + &attested, + &controls([ChainControl::LogCoverage(parent)]), + ) + .expect_err("a regressing watermark must be rejected"); + assert!( + format!("{error:?}").contains("log coverage"), + "the error must name the rejected watermark: {error:?}" + ); +} + +/// Logs cannot be attested complete for a block whose canonical identity the +/// source has not established. Allowing it would let a source vouch for blocks +/// it has never seen. +#[test] +fn attestation_cannot_outrun_canonical_coverage() { + let (state, _, head) = state(); + let ahead = block( + head.number + 1, + B256::repeat_byte(0x04), + B256::repeat_byte(0x03), + ); + + assert!(matches!( + validate_canonical_sequence(&state, &controls([ChainControl::LogCoverage(ahead)])), + Err(ReactiveError::InvalidChainControl { .. }) + )); +} + +/// A watermark naming a block at a retained height but with a different hash is +/// attesting a branch this state never had. +#[test] +fn attestation_identity_must_agree_with_retained_history() { + let (state, _, head) = state(); + let impostor = block( + head.number, + B256::repeat_byte(0xee), + B256::repeat_byte(0x02), + ); + + assert!(matches!( + validate_canonical_sequence(&state, &controls([ChainControl::LogCoverage(impostor)])), + Err(ReactiveError::InvalidChainControl { .. }) + )); +} + +/// Attesting an older retained block is legitimate — a source may be behind — +/// and must round-trip without disturbing canonical coverage. +#[test] +fn attesting_a_retained_ancestor_is_valid_and_lags_behind_coverage() { + let (state, parent, head) = state(); + + let validation = + validate_canonical_sequence(&state, &controls([ChainControl::LogCoverage(parent)])) + .expect("a lagging attestation is valid"); + + assert_eq!(validation.next_state().log_coverage_head(), Some(&parent)); + assert_eq!(validation.next_state().coverage_head(), Some(&head)); +} + +/// The watermark is carried on the validation snapshot, so an external +/// checkpoint owner can persist and restore it alongside the canonical heads. +#[test] +fn watermark_survives_a_state_snapshot_round_trip() { + let (state, _, head) = state(); + let attested = + validate_canonical_sequence(&state, &controls([ChainControl::LogCoverage(head)])) + .expect("valid attestation") + .next_state() + .clone(); + + let restored = CanonicalSequenceState::new( + attested.retained_canonical_history().to_vec(), + attested.coverage_head().copied(), + attested.safe_head().copied(), + attested.finalized_head().copied(), + ) + .with_log_coverage_head(attested.log_coverage_head().copied()); + + assert_eq!(restored.log_coverage_head(), Some(&head)); + restored.validate().expect("restored state is coherent"); +} + +/// A canonical header record so the runtime establishes coverage before the +/// attestation that references it. +fn header_record(point: BlockRef) -> ReactiveInputRecord { + ReactiveInputRecord::new( + ReactiveInput::BlockHeader(alloy_rpc_types_eth::Header { + hash: point.hash, + inner: alloy_consensus::Header { + number: point.number, + parent_hash: point.parent_hash.unwrap_or_default(), + timestamp: point.timestamp.unwrap_or_default(), + ..alloy_consensus::Header::default() + }, + total_difficulty: None, + size: None, + }), + ReactiveContext { + chain_id: None, + source: InputSource::Subscription, + chain_status: ChainStatus::Included { + block: point, + confirmations: 0, + }, + block: Some(point), + transaction_index: None, + log_index: None, + }, + ) +} + +/// End-to-end through the runtime: this is the value a consumer reads to decide +/// whether it may treat its buffered log set as authoritative, so it must be +/// absent until attested and must not be confused with canonical progress. +#[tokio::test] +async fn runtime_exposes_the_attested_watermark_only_once_attested() -> Result<()> { + let head = block(41, B256::repeat_byte(0x41), B256::repeat_byte(0x40)); + let mut cache = setup_cache().await?; + let mut runtime = ReactiveRuntime::::new(ReactiveConfig::default()); + + // Canonical progress alone attests nothing about log completeness. + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::new(vec![header_record(head)]), + )?; + assert_eq!( + runtime.log_coverage_head(), + None, + "coverage progress must never imply log completeness" + ); + + runtime.ingest_batch( + &mut cache, + ReactiveInputBatch::::new(Vec::new()) + // A control-only batch must name its chain authoritatively. + .with_chain_id(1) + .with_chain_controls([ChainControl::LogCoverage(head)]), + )?; + + assert_eq!(runtime.log_coverage_head(), Some(&head)); + Ok(()) +} diff --git a/tests/reactive_rpc_stats.rs b/tests/reactive_rpc_stats.rs new file mode 100644 index 0000000..2aeb3e6 --- /dev/null +++ b/tests/reactive_rpc_stats.rs @@ -0,0 +1,367 @@ +//! Attributed provider-request accounting for the Alloy subscriber. +//! +//! `SubscriberRpcStats` exists so RPC consumption is diagnosable from inside the +//! process rather than from a provider invoice. These tests pin the property +//! that makes it trustworthy: the counters agree exactly with the requests the +//! mocked transport actually served, and each one is attributed to the mechanism +//! that caused it — never to the shared fetch helper both mechanisms call. +//! +//! The polling transport is used because it is the mockable one: `eth_newFilter` +//! stands in for the pubsub `eth_subscribe` handshake, and both are counted as +//! [`SubscriberRpcMethod::EthSubscribe`] stream installation. +#![cfg(feature = "reactive")] + +#[cfg(feature = "reactive-polling")] +use alloy_network::Ethereum; +#[cfg(feature = "reactive-polling")] +use alloy_primitives::{Address, B256, Bytes, Log as PrimitiveLog, U256, keccak256}; +#[cfg(feature = "reactive-polling")] +use alloy_provider::ProviderBuilder; +#[cfg(feature = "reactive-polling")] +use alloy_rpc_types_eth::{Block, Filter, Header, Log}; +#[cfg(feature = "reactive-polling")] +use alloy_transport::mock::Asserter; +#[cfg(feature = "reactive-polling")] +use anyhow::Result; + +#[cfg(feature = "reactive-polling")] +use evm_fork_cache::reactive::{ + AlloySubscriber, BlockRef, EventSubscriber, HandlerId, LogInterest, ReactiveInterest, + SubscriberBackfill, SubscriberConfig, SubscriberMode, SubscriberOwnerStart, +}; +use evm_fork_cache::reactive::{SubscriberRpcCause, SubscriberRpcMethod, SubscriberRpcStats}; + +/// Every cause/method pair that carried at least one request, as a sorted vector +/// so an assertion can name the complete expected set instead of probing it one +/// counter at a time. +fn attributed(stats: &SubscriberRpcStats) -> Vec<(SubscriberRpcCause, SubscriberRpcMethod, u64)> { + stats.nonzero().collect() +} + +#[cfg(feature = "reactive-polling")] +fn rpc_log(address: Address, topic0: B256, block_number: u64, log_index: u64) -> Log { + Log { + inner: PrimitiveLog::new_unchecked(address, vec![topic0], Bytes::new()), + block_hash: Some(B256::repeat_byte(block_number as u8)), + block_number: Some(block_number), + block_timestamp: Some(1_700_000_000 + block_number), + transaction_hash: Some(B256::repeat_byte(0x20 + log_index as u8)), + transaction_index: Some(log_index), + log_index: Some(log_index), + removed: false, + } +} + +#[cfg(feature = "reactive-polling")] +fn rpc_block(point: &BlockRef) -> Block { + Block::empty(Header { + hash: point.hash, + inner: alloy_consensus::Header { + number: point.number, + parent_hash: point.parent_hash.unwrap_or_default(), + timestamp: point.timestamp.unwrap_or_default(), + ..Default::default() + }, + total_difficulty: None, + size: None, + }) +} + +#[cfg(feature = "reactive-polling")] +fn polling_subscriber( + asserter: Asserter, +) -> AlloySubscriber + Clone, Ethereum> { + let provider = ProviderBuilder::new().connect_mocked_client(asserter); + AlloySubscriber::new( + provider, + SubscriberMode::Polling, + SubscriberConfig { + max_batch_size: 16, + ..SubscriberConfig::default() + }, + ) +} + +#[cfg(feature = "reactive-polling")] +fn log_interest(address: Address, topic: B256) -> ReactiveInterest { + ReactiveInterest::Logs(LogInterest { + provider_filter: Filter::new().address(address).event_signature(topic), + local_matcher: None, + route_key: None, + }) +} + +#[test] +fn a_fresh_subscriber_reports_no_provider_requests() { + let stats = SubscriberRpcStats::default(); + + assert_eq!(stats.total(), 0); + assert!(attributed(&stats).is_empty()); + for cause in SubscriberRpcCause::ALL { + assert_eq!(stats.by_cause(cause), 0); + } + for method in SubscriberRpcMethod::ALL { + assert_eq!(stats.by_method(method), 0); + } +} + +/// The two reporting dimensions are views of one matrix, so they must always +/// sum to the same total. A regression here would make a report silently +/// under-count. +#[test] +fn reporting_dimensions_are_consistent_by_construction() { + let stats = SubscriberRpcStats::default(); + let by_cause: u64 = SubscriberRpcCause::ALL + .into_iter() + .map(|cause| stats.by_cause(cause)) + .sum(); + let by_method: u64 = SubscriberRpcMethod::ALL + .into_iter() + .map(|method| stats.by_method(method)) + .sum(); + let by_entry: u64 = stats.entries().map(|(_, _, requests)| requests).sum(); + + assert_eq!(by_cause, stats.total()); + assert_eq!(by_method, stats.total()); + assert_eq!(by_entry, stats.total()); + assert_eq!( + stats.entries().count(), + SubscriberRpcCause::COUNT * SubscriberRpcMethod::COUNT + ); +} + +/// Bulk owner catch-up: exactly one chain-identity read, one stream +/// installation, two target certifications, and one `eth_getLogs` — all +/// attributed to [`SubscriberRpcCause::OwnerReconcile`]. +#[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "reactive-polling")] +async fn owner_reconcile_attributes_every_provider_request() -> Result<()> { + let asserter = Asserter::new(); + let pool = Address::repeat_byte(0xb1); + let topic = keccak256(b"Swap()"); + let baseline = BlockRef { + number: 100, + hash: B256::repeat_byte(0x64), + parent_hash: Some(B256::repeat_byte(0x63)), + timestamp: Some(1_700_000_100), + }; + let through = BlockRef { + number: 101, + hash: B256::repeat_byte(0x65), + parent_hash: Some(B256::repeat_byte(0x64)), + timestamp: Some(1_700_000_101), + }; + + // eth_newFilter, eth_chainId, target certification, eth_getLogs, final + // certification — the exact sequence `reconcile_interest_owners` issues. + asserter.push_success(&U256::from(1)); + asserter.push_success(&U256::from(1)); + asserter.push_success(&Some(rpc_block(&through))); + asserter.push_success(&vec![rpc_log(pool, topic, 101, 0)]); + asserter.push_success(&Some(rpc_block(&through))); + + let mut subscriber = polling_subscriber(asserter.clone()); + let epoch = subscriber.stage_interest_owner( + HandlerId::new("stats-pool"), + &[log_interest(pool, topic)], + SubscriberOwnerStart::PostBlock(baseline), + )?; + subscriber + .reconcile_interest_owners(std::slice::from_ref(&epoch), through) + .await?; + + assert!( + asserter.read_q().is_empty(), + "the mocked transport must have served every queued response" + ); + + let stats = subscriber.rpc_stats(); + assert_eq!( + attributed(&stats), + vec![ + ( + SubscriberRpcCause::ChainIdentity, + SubscriberRpcMethod::EthChainId, + 1 + ), + ( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + 1 + ), + ( + SubscriberRpcCause::OwnerReconcile, + SubscriberRpcMethod::EthGetBlockByNumber, + 2 + ), + ( + SubscriberRpcCause::OwnerReconcile, + SubscriberRpcMethod::EthGetLogs, + 1 + ), + ], + "every served request must be attributed, and nothing else counted" + ); + assert_eq!(stats.total(), 5); + assert_eq!(stats.by_method(SubscriberRpcMethod::EthGetLogs), 1); + assert_eq!(stats.by_cause(SubscriberRpcCause::OwnerReconcile), 3); + + Ok(()) +} + +/// A queued lazy backfill issues the same shaped requests through the same +/// helper as bulk reconcile, and must still be attributed to its own cause. +/// This is the property that makes an unexpected bill diagnosable: knowing the +/// method alone would not distinguish these two mechanisms. +#[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "reactive-polling")] +async fn lazy_backfill_is_attributed_apart_from_owner_reconcile() -> Result<()> { + let asserter = Asserter::new(); + let pool = Address::repeat_byte(0xcd); + let topic = keccak256(b"Swap()"); + let through = BlockRef { + number: 42, + hash: B256::repeat_byte(42), + parent_hash: Some(B256::repeat_byte(41)), + timestamp: Some(1_700_000_042), + }; + + // A bounded range needs no head resolution: certification, logs, + // certification, behind the stream installation and chain-identity read. + asserter.push_success(&U256::from(1)); + asserter.push_success(&U256::from(1)); + asserter.push_success(&Some(rpc_block(&through))); + asserter.push_success(&vec![rpc_log(pool, topic, 42, 3)]); + asserter.push_success(&Some(rpc_block(&through))); + + let mut subscriber = polling_subscriber(asserter.clone()); + subscriber.add_interest_owner_with_backfill( + HandlerId::new("stats-lazy-pool"), + &[log_interest(pool, topic)], + SubscriberBackfill::range(40, 42), + )?; + let batch = subscriber + .next_batch() + .await? + .expect("queued backfill must deliver its owner-scoped batch"); + assert_eq!(batch.records().len(), 1); + + assert!( + asserter.read_q().is_empty(), + "the mocked transport must have served every queued response" + ); + + let stats = subscriber.rpc_stats(); + assert_eq!( + attributed(&stats), + vec![ + ( + SubscriberRpcCause::ChainIdentity, + SubscriberRpcMethod::EthChainId, + 1 + ), + ( + SubscriberRpcCause::StreamSubscription, + SubscriberRpcMethod::EthSubscribe, + 1 + ), + ( + SubscriberRpcCause::LazyBackfill, + SubscriberRpcMethod::EthGetBlockByNumber, + 2 + ), + ( + SubscriberRpcCause::LazyBackfill, + SubscriberRpcMethod::EthGetLogs, + 1 + ), + ] + ); + assert_eq!( + stats.by_cause(SubscriberRpcCause::OwnerReconcile), + 0, + "a lazy backfill must not be charged to bulk owner reconciliation" + ); + assert_eq!(stats.by_cause(SubscriberRpcCause::LazyBackfill), 3); + + Ok(()) +} + +/// `reset_rpc_stats` opens a new measurement window without disturbing the +/// subscriber, and takes `&self` so it can be called while catch-up work holds +/// the subscriber mutably elsewhere. +#[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "reactive-polling")] +async fn reset_rpc_stats_opens_a_new_measurement_window() -> Result<()> { + let asserter = Asserter::new(); + let pool = Address::repeat_byte(0xa7); + let topic = keccak256(b"Swap()"); + let through = BlockRef { + number: 42, + hash: B256::repeat_byte(42), + parent_hash: Some(B256::repeat_byte(41)), + timestamp: Some(1_700_000_042), + }; + asserter.push_success(&U256::from(1)); + asserter.push_success(&U256::from(1)); + asserter.push_success(&Some(rpc_block(&through))); + asserter.push_success(&vec![rpc_log(pool, topic, 42, 3)]); + asserter.push_success(&Some(rpc_block(&through))); + + let mut subscriber = polling_subscriber(asserter.clone()); + subscriber.add_interest_owner_with_backfill( + HandlerId::new("stats-window-pool"), + &[log_interest(pool, topic)], + SubscriberBackfill::range(40, 42), + )?; + let _ = subscriber.next_batch().await?; + assert!(subscriber.rpc_stats().total() > 0); + + subscriber.reset_rpc_stats(); + + let stats = subscriber.rpc_stats(); + assert_eq!(stats.total(), 0); + assert!(attributed(&stats).is_empty()); + assert_eq!( + subscriber.chain_id(), + Some(1), + "resetting a counter window must not disturb resolved subscriber state" + ); + + Ok(()) +} + +/// Labels are part of the diagnostic contract: a metrics export keys on them, so +/// they must stay stable and must not collide. +#[test] +fn cause_and_method_labels_are_stable_and_distinct() { + assert_eq!(SubscriberRpcMethod::EthGetLogs.as_str(), "eth_getLogs"); + assert_eq!( + SubscriberRpcMethod::EthGetBlockByNumber.to_string(), + "eth_getBlockByNumber" + ); + assert_eq!( + SubscriberRpcCause::CanonicalHeadCertification.as_str(), + "canonical_head_certification" + ); + assert_eq!( + SubscriberRpcCause::LazyBackfill.to_string(), + "lazy_backfill" + ); + + let mut method_labels: Vec<_> = SubscriberRpcMethod::ALL + .into_iter() + .map(SubscriberRpcMethod::as_str) + .collect(); + method_labels.sort_unstable(); + method_labels.dedup(); + assert_eq!(method_labels.len(), SubscriberRpcMethod::COUNT); + + let mut cause_labels: Vec<_> = SubscriberRpcCause::ALL + .into_iter() + .map(SubscriberRpcCause::as_str) + .collect(); + cause_labels.sort_unstable(); + cause_labels.dedup(); + assert_eq!(cause_labels.len(), SubscriberRpcCause::COUNT); +} diff --git a/tests/reactive_runtime.rs b/tests/reactive_runtime.rs index d2d2333..cc34806 100644 --- a/tests/reactive_runtime.rs +++ b/tests/reactive_runtime.rs @@ -1027,7 +1027,7 @@ async fn reactive_runtime_batches_exact_resync_cancellation_in_queue_order() -> assert_eq!(cancelled.len(), 2 * BACKLOG_EVENTS as usize); assert!( - cancelled.chunks_exact(2).all(|requests| { + cancelled.as_chunks::<2>().0.iter().all(|requests| { requests[0].id == ResyncId::new("pool-a") && requests[1].id == ResyncId::new("pool-b") }), "cancelled requests retain pending-queue order, not caller ID order"