evm_fork_cache/events/mod.rs
1//! Event → state pipeline (Pillar B.2 — the *reader half* of the event pipeline).
2//!
3//! Phase 3 ([`state_update`](crate::state_update)) built the *writer half*: the
4//! generic [`StateUpdate`] vocabulary and the cold-aware
5//! [`apply_updates`](crate::cache::EvmCache::apply_updates) that consumes it. This
6//! module builds the *reader half*: it turns an on-chain [`Log`] into that same
7//! vocabulary and drives it through the cache, keeping event-derived state
8//! reactively fresh.
9//!
10//! # The flow
11//!
12//! ```text
13//! Log ─▶ EventDecoder::decode(log, &StateView) ─▶ Vec<StateUpdate>
14//! │
15//! apply_updates ▼
16//! EvmCache (+ StateDiff)
17//! ```
18//!
19//! A [`DecoderRegistry`] dispatches a log to the decoders registered for its
20//! emitting address (plus any global decoders) and concatenates their output. An
21//! [`EventPipeline`] orchestrates a block's logs: [`ingest_logs`] decodes and
22//! applies them **log-by-log in order** (so a later log's decode observes the
23//! effects of earlier ones through the [`StateView`]), [`reorg_to`] purges the
24//! addresses touched after a new head, and [`reconcile`] re-reads sampled
25//! event-derived slots against chain truth (correct **and** alarm).
26//!
27//! [`ingest_logs`]: EventPipeline::ingest_logs
28//! [`reorg_to`]: EventPipeline::reorg_to
29//! [`reconcile`]: EventPipeline::reconcile
30//!
31//! # Decoders are pure data functions
32//!
33//! [`EventDecoder::decode`] is a pure function of `(log, pre-state)`: it performs
34//! no I/O and emits serializable, replayable [`StateUpdate`] data. Most updates
35//! need no pre-state ([`SlotDelta`](crate::StateUpdate::SlotDelta) and
36//! [`SlotMasked`](crate::StateUpdate::SlotMasked) are read-modify-write *at apply
37//! time*), but stateful external adapters may read the narrow read-only
38//! [`StateView`] to compute a post-state from cached pre-state. The view never touches RPC; a
39//! slot absent from the cache reads `None` (cold), and a decoder that cannot
40//! compute against a cold word surfaces a skip rather than inventing a value.
41//!
42//! # `!Send` cache discipline
43//!
44//! [`EvmCache`] is `!Send` (it owns the mutable fork and
45//! blocks on RPC internally). All of [`EventPipeline`]'s core methods
46//! ([`ingest_logs`](EventPipeline::ingest_logs) /
47//! [`reorg_to`](EventPipeline::reorg_to) /
48//! [`reconcile`](EventPipeline::reconcile)) take `&mut EvmCache` and are
49//! **synchronous** — they never `.await`, so the cache is never held across a
50//! yield point. This is what makes the core deterministically testable offline.
51//! The async [`drive`] convenience holds the cache only across the *log source*
52//! await (the source future is `Send`; the cache is untouched during it).
53//!
54//! # Freshness wiring
55//!
56//! [`BlockDigest::touched_slots`] surfaces the `(address, slot)` set written for a
57//! block so a caller can classify event-derived slots in a
58//! [`FreshnessRegistry`](crate::freshness::FreshnessRegistry) — typically pin them
59//! ([`Validity::Pinned`](crate::freshness::Validity::Pinned)) or mark them
60//! [`Validity::ValidThrough`](crate::freshness::Validity::ValidThrough) so the
61//! optimistic validator does not waste RPC re-verifying state the pipeline keeps
62//! fresh — then call
63//! [`FreshnessController::on_new_block`](crate::freshness::FreshnessController::on_new_block).
64//! No controller internals change. Periodically call
65//! [`reconcile`](EventPipeline::reconcile) to sample-check those slots against the
66//! chain (honest freshness).
67
68pub mod erc20;
69
70use std::collections::{HashMap, HashSet, VecDeque};
71use std::sync::Arc;
72
73use alloy_primitives::{Address, Log, U256};
74
75use crate::cache::EvmCache;
76use crate::errors::CacheResult as Result;
77use crate::freshness::SlotChange;
78use crate::state_update::{PurgeScope, StateDiff, StateUpdate};
79
80/// Read-only view of current cached state handed to a decoder.
81///
82/// Decoders that compute post-state from pre-state read through this; stateless
83/// decoders (such as the built-in ERC-20 `Transfer` decoder) ignore it. The view
84/// never touches RPC — a slot absent from the cache reads `None`.
85pub trait StateView {
86 /// Current cached value of `(address, slot)` (overlay ▸ backend ▸ `None`),
87 /// matching what the EVM would `SLOAD` (`account_state`-aware). `None` means
88 /// the slot is **cold** — neither cache layer has seen it.
89 fn storage(&self, address: Address, slot: U256) -> Option<U256>;
90}
91
92/// Decode one log into zero or more targeted [`StateUpdate`]s.
93///
94/// `decode` is a pure function of `(log, pre-state)`: it performs no I/O and emits
95/// data (the updates are serializable and replayable against matching pre-state).
96/// The pipeline applies the result through
97/// [`apply_updates`](crate::cache::EvmCache::apply_updates).
98///
99/// A decoder returns `vec![]` for any log it does not recognise (wrong topic0, an
100/// unregistered emitting address, a malformed payload). The pipeline counts a log
101/// as *decoded* only when some decoder produced at least one update for it.
102pub trait EventDecoder: Send + Sync {
103 /// Decode `log` against the read-only pre-state `view` into targeted updates.
104 fn decode(&self, log: &Log, view: &dyn StateView) -> Vec<StateUpdate>;
105}
106
107/// Dispatches a log to the decoders registered for its emitting address (and any
108/// global decoders) and concatenates their output.
109///
110/// Dispatch is by emitting address ([`Log::address`]); topic0 filtering is each
111/// decoder's own concern (a decoder returns `vec![]` for a log it does not
112/// recognise). Address-scoped decoders are consulted first, then global ones, and
113/// the per-decoder outputs are concatenated in that order.
114#[derive(Default)]
115pub struct DecoderRegistry {
116 /// Decoders consulted for every log, in registration order.
117 global: Vec<Arc<dyn EventDecoder>>,
118 /// Decoders consulted only for logs emitted by a specific address.
119 per_address: HashMap<Address, Vec<Arc<dyn EventDecoder>>>,
120}
121
122impl DecoderRegistry {
123 /// Create an empty registry with no decoders.
124 pub fn new() -> Self {
125 Self::default()
126 }
127
128 /// Register a decoder consulted for **every** log.
129 pub fn register(&mut self, decoder: Arc<dyn EventDecoder>) -> &mut Self {
130 self.global.push(decoder);
131 self
132 }
133
134 /// Register a decoder consulted only for logs emitted by `address`.
135 pub fn register_for_address(
136 &mut self,
137 address: Address,
138 decoder: Arc<dyn EventDecoder>,
139 ) -> &mut Self {
140 self.per_address.entry(address).or_default().push(decoder);
141 self
142 }
143
144 /// Decode `log` through every applicable decoder, concatenating the results
145 /// (address-scoped decoders first, then global), preserving order.
146 pub fn decode(&self, log: &Log, view: &dyn StateView) -> Vec<StateUpdate> {
147 let mut out = Vec::new();
148 if let Some(scoped) = self.per_address.get(&log.address) {
149 for decoder in scoped {
150 out.extend(decoder.decode(log, view));
151 }
152 }
153 for decoder in &self.global {
154 out.extend(decoder.decode(log, view));
155 }
156 out
157 }
158}
159
160/// How a reorg purges the addresses touched after the new head.
161///
162/// `depth` bounds the per-block touched-address history retained for reorg purge
163/// (the reorg horizon); older entries are dropped as new blocks are ingested.
164/// `scope` is the [`PurgeScope`] applied to each touched address on
165/// [`reorg_to`](EventPipeline::reorg_to).
166#[derive(Clone, Debug)]
167pub struct ReorgConfig {
168 /// How many recent blocks of touched-address history to retain for reorg
169 /// purge (the reorg horizon). Older entries are dropped.
170 pub depth: usize,
171 /// Purge scope used on reorg. The default ([`PurgeScope::AllStorage`]) drops
172 /// storage so it re-fetches but keeps the account header;
173 /// [`PurgeScope::Account`] drops the whole account.
174 pub scope: PurgeScope,
175}
176
177impl Default for ReorgConfig {
178 fn default() -> Self {
179 Self {
180 depth: 64,
181 scope: PurgeScope::AllStorage,
182 }
183 }
184}
185
186/// Per-block result of [`EventPipeline::ingest_logs`].
187#[derive(Clone, Debug, Default)]
188pub struct BlockDigest {
189 /// The block whose logs were ingested.
190 pub block: u64,
191 /// Merged diff of everything applied for the block (changes-only **and**
192 /// skips — check [`StateDiff::has_skipped`]).
193 pub applied: StateDiff,
194 /// Number of logs that decoded to at least one update.
195 pub decoded_logs: usize,
196 /// The `(address, slot)` set written this block (for freshness
197 /// classification — see the module docs).
198 pub touched_slots: Vec<(Address, U256)>,
199}
200
201/// Result of [`EventPipeline::reconcile`].
202#[derive(Clone, Debug, Default)]
203pub struct ReconcileReport {
204 /// How many slots were sampled.
205 pub checked: usize,
206 /// Slots whose event-derived value disagreed with chain truth. A non-empty
207 /// list is a **drift alarm**: the cache had drifted and
208 /// [`verify_slots`](crate::cache::EvmCache::verify_slots) has now injected the
209 /// fresh chain values (correct + alarm).
210 pub mismatched: Vec<SlotChange>,
211}
212
213/// Orchestrates decoding, applying, reorg handling, and reconciliation of a
214/// block's logs against an [`EvmCache`].
215///
216/// Construct one from a [`DecoderRegistry`], then call
217/// [`ingest_logs`](Self::ingest_logs) per block. See the [module docs](crate::events)
218/// for the freshness-wiring pattern (event-derived slots →
219/// [`Pinned`](crate::freshness::Validity::Pinned), reconciled periodically).
220pub struct EventPipeline {
221 registry: DecoderRegistry,
222 reorg: ReorgConfig,
223 /// Ring of `(block, touched addresses)` for reorg purge, newest at the back,
224 /// bounded to `reorg.depth`.
225 touched: VecDeque<(u64, Vec<Address>)>,
226 /// Ring of `(block, event-derived (address, slot) pairs)`, newest at the
227 /// back, bounded to the reorg horizon `reorg.depth` in lockstep with
228 /// `touched`. Only the most-recent `depth` blocks' derived slots are
229 /// retained (the reconcile sampling source); older entries age out as new
230 /// blocks are ingested, so steady-state ingestion does not grow unbounded.
231 derived: VecDeque<(u64, HashSet<(Address, U256)>)>,
232}
233
234impl EventPipeline {
235 /// Create a pipeline over `registry` with the default [`ReorgConfig`].
236 pub fn new(registry: DecoderRegistry) -> Self {
237 Self {
238 registry,
239 reorg: ReorgConfig::default(),
240 touched: VecDeque::new(),
241 derived: VecDeque::new(),
242 }
243 }
244
245 /// Override the [`ReorgConfig`] (reorg horizon depth + purge scope).
246 pub fn with_reorg_config(mut self, cfg: ReorgConfig) -> Self {
247 self.reorg = cfg;
248 self
249 }
250
251 /// Decode + apply a block's logs, **log-by-log in order**, recording touched
252 /// state for reorg tracking. Returns the per-block [`BlockDigest`].
253 ///
254 /// Each log is decoded against the *current* cache state and applied
255 /// immediately, so a later log's decode observes the effects of earlier logs
256 /// in the same block through the [`StateView`] (e.g. a same-block `Burn` after
257 /// a `Mint`, or two overlapping `Mint`s). The touched addresses are recorded
258 /// in the depth-bounded reorg ring under `block`, and the touched
259 /// `(address, slot)` pairs into the parallel depth-bounded reconcile-sampling
260 /// ring.
261 pub fn ingest_logs(&mut self, cache: &mut EvmCache, block: u64, logs: &[Log]) -> BlockDigest {
262 let mut digest = BlockDigest {
263 block,
264 ..Default::default()
265 };
266 let mut touched_addrs: HashSet<Address> = HashSet::new();
267 let mut block_derived: HashSet<(Address, U256)> = HashSet::new();
268
269 for log in logs {
270 // Decode against the current cache view (immutable borrow), then drop
271 // that borrow before taking the &mut borrow for apply. Decode returns
272 // owned data, so the two borrows never overlap.
273 let updates = self.registry.decode(log, &*cache);
274 if updates.is_empty() {
275 continue;
276 }
277 let diff = cache.apply_updates(&updates);
278
279 // A log counts as decoded when it produced at least one update.
280 digest.decoded_logs += 1;
281
282 // Record touched addresses (for reorg) and touched slots (for
283 // freshness + reconcile) from every category of the diff.
284 for change in &diff.slots {
285 touched_addrs.insert(change.address);
286 Self::note_touched_slot(
287 &mut digest,
288 &mut block_derived,
289 change.address,
290 change.slot,
291 );
292 }
293 for change in &diff.accounts {
294 touched_addrs.insert(change.address);
295 }
296 for record in &diff.purged {
297 touched_addrs.insert(record.address);
298 }
299 for skip in &diff.skipped {
300 touched_addrs.insert(skip.address);
301 Self::note_touched_slot(&mut digest, &mut block_derived, skip.address, skip.slot);
302 }
303 for skip in &diff.skipped_balances {
304 touched_addrs.insert(skip.address);
305 }
306 for skip in &diff.skipped_masks {
307 touched_addrs.insert(skip.address);
308 Self::note_touched_slot(&mut digest, &mut block_derived, skip.address, skip.slot);
309 }
310 // Skipped (cold) `Account` patches carry an address but no slot, so
311 // track the address only — mirroring `skipped_balances`. A third-party
312 // decoder emitting `StateUpdate::Account` against a cold account would
313 // otherwise be invisible to reorg purge and freshness tracking.
314 for skip in &diff.skipped_accounts {
315 touched_addrs.insert(skip.address);
316 }
317
318 digest.applied.merge(diff);
319 }
320
321 if !touched_addrs.is_empty() {
322 self.touched
323 .push_back((block, touched_addrs.into_iter().collect()));
324 if !block_derived.is_empty() {
325 self.derived.push_back((block, block_derived));
326 }
327 self.trim_ring();
328 }
329
330 digest
331 }
332
333 /// Reorg to `new_head`: purge (per [`ReorgConfig::scope`]) every address
334 /// touched in a block **>** `new_head`, drop those ring entries, and return the
335 /// merged purge [`StateDiff`].
336 ///
337 /// The next read of a purged address re-fetches from RPC. The caller then
338 /// re-ingests the canonical chain's logs for the reorged range (and/or the
339 /// next read lazily re-fetches).
340 pub fn reorg_to(&mut self, cache: &mut EvmCache, new_head: u64) -> StateDiff {
341 // Collect the addresses touched strictly after the new head, deduped.
342 let mut to_purge: HashSet<Address> = HashSet::new();
343 for (block, addrs) in &self.touched {
344 if *block > new_head {
345 to_purge.extend(addrs.iter().copied());
346 }
347 }
348
349 // Drop the rolled-back ring entries and the derived slots they own.
350 self.touched.retain(|(block, _)| *block <= new_head);
351 self.derived.retain(|(block, _)| *block <= new_head);
352
353 let updates: Vec<StateUpdate> = to_purge
354 .into_iter()
355 .map(|addr| StateUpdate::purge(addr, self.reorg.scope.clone()))
356 .collect();
357 cache.apply_updates(&updates)
358 }
359
360 /// Sampled reconciliation: re-read `slots` via
361 /// [`EvmCache::verify_slots`](crate::cache::EvmCache::verify_slots) (correct +
362 /// alarm). Returns the mismatches.
363 ///
364 /// It fetches the fresh chain value for each slot, injects the ones that
365 /// changed (so the cache is **corrected**), and returns the changed set — a
366 /// non-empty [`ReconcileReport::mismatched`] is the **drift alarm**:
367 /// event-derived state had drifted and has now been corrected to chain truth.
368 /// Honest about reachability (via
369 /// [`EvmCache::reconcile_slots`](crate::cache::EvmCache::reconcile_slots)): it
370 /// errors when no batch fetcher is configured **or** when a non-empty request
371 /// could not fetch any slot (a total fetch failure is not a silent all-clear).
372 /// An empty `slots` is a no-op that returns an empty report.
373 pub fn reconcile(
374 &mut self,
375 cache: &mut EvmCache,
376 slots: &[(Address, U256)],
377 ) -> Result<ReconcileReport> {
378 let mismatched = cache.reconcile_slots(slots)?;
379 Ok(ReconcileReport {
380 checked: slots.len(),
381 mismatched,
382 })
383 }
384
385 /// All event-derived slots retained within the reorg horizon (the sampling
386 /// source for [`reconcile`](Self::reconcile)).
387 ///
388 /// Flattens the block-horizon ring and dedupes, so each `(address, slot)`
389 /// pair is yielded exactly once even if it was touched in more than one
390 /// retained block (set semantics preserved).
391 pub fn derived_slots(&self) -> impl Iterator<Item = (Address, U256)> + '_ {
392 let mut seen: HashSet<(Address, U256)> = HashSet::new();
393 for (_, pairs) in &self.derived {
394 seen.extend(pairs.iter().copied());
395 }
396 seen.into_iter()
397 }
398
399 /// Record a touched slot in both the per-block digest (deduped within the
400 /// block) and the current block's derived set (later pushed onto the bounded
401 /// reconcile-sampling ring).
402 fn note_touched_slot(
403 digest: &mut BlockDigest,
404 block_derived: &mut HashSet<(Address, U256)>,
405 address: Address,
406 slot: U256,
407 ) {
408 block_derived.insert((address, slot));
409 if !digest.touched_slots.contains(&(address, slot)) {
410 digest.touched_slots.push((address, slot));
411 }
412 }
413
414 /// Trim both reorg-horizon rings (`touched` and `derived`) to the configured
415 /// depth in lockstep, dropping the oldest front entries.
416 fn trim_ring(&mut self) {
417 while self.touched.len() > self.reorg.depth {
418 self.touched.pop_front();
419 }
420 while self.derived.len() > self.reorg.depth {
421 self.derived.pop_front();
422 }
423 }
424}
425
426/// A signalled reorg accompanying a block from a [`LogSource`].
427///
428/// `None` means the block extends the current head; `Some(new_head)` asks the
429/// driver to [`reorg_to`](EventPipeline::reorg_to) `new_head` before ingesting.
430pub type ReorgSignal = Option<u64>;
431
432/// An async source of blocks of logs for [`drive`].
433///
434/// This is the thin async convenience layer (§7.5): a production WS /
435/// `subscribe_logs` adapter implements it; the offline example feeds a vec-backed
436/// source. The synchronous [`EventPipeline`] core is the tested contract.
437pub trait LogSource {
438 /// Yield the next block: its number, its logs, and an optional reorg signal.
439 /// `None` ends the stream.
440 fn next_block(
441 &mut self,
442 ) -> impl std::future::Future<Output = Option<(u64, Vec<Log>, ReorgSignal)>> + Send;
443}
444
445/// Drive `pipeline` over `source`, ingesting each block (reorging first when
446/// signalled) and invoking `on_block` after each ingest.
447///
448/// A thin async convenience over the synchronous core: it pulls a block from the
449/// `Send` source (the only `.await`), then synchronously
450/// [`reorg_to`](EventPipeline::reorg_to) (if signalled) and
451/// [`ingest_logs`](EventPipeline::ingest_logs), holding the `!Send` cache only
452/// across the synchronous section. `on_block` is where a caller wires
453/// [`FreshnessController::on_new_block`](crate::freshness::FreshnessController::on_new_block)
454/// and freshness classification of the digest's touched slots.
455pub async fn drive<S, F>(
456 pipeline: &mut EventPipeline,
457 cache: &mut EvmCache,
458 mut source: S,
459 mut on_block: F,
460) where
461 S: LogSource,
462 F: FnMut(&BlockDigest),
463{
464 while let Some((block, logs, reorg)) = source.next_block().await {
465 if let Some(new_head) = reorg {
466 pipeline.reorg_to(cache, new_head);
467 }
468 let digest = pipeline.ingest_logs(cache, block, &logs);
469 on_block(&digest);
470 }
471}