Skip to main content

evm_fork_cache/reactive/
raw_json_flashblocks.rs

1use std::{
2    collections::{HashMap, HashSet},
3    time::Instant,
4};
5
6use alloy_primitives::{Address, B256, Bytes, FixedBytes, Keccak256, Log as PrimitiveLog};
7use alloy_rpc_types_eth::Log;
8use tokio::sync::{mpsc, oneshot};
9
10use super::{
11    BaseFlashblockBase, FlashblockContentCommitment, FlashblockIngressTiming, FlashblockRef,
12    ProviderRef, deserialize_optional_rpc_u64, flashblock_content_hash,
13    flashblock_transaction_hashes, non_placeholder_hash,
14};
15
16/// Resource bounds applied while converting receipt-enriched JSON Flashblocks.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct RawJsonFlashblocksLimits {
20    /// Largest accepted JSON frame.
21    pub max_frame_bytes: usize,
22    /// Largest accepted index for one payload generation.
23    pub max_flashblocks_per_payload: usize,
24    /// Largest cumulative transaction membership retained for one generation.
25    pub max_transactions_per_payload: usize,
26    /// Largest cumulative receipt-log count retained for one generation.
27    pub max_logs_per_payload: usize,
28}
29
30impl Default for RawJsonFlashblocksLimits {
31    fn default() -> Self {
32        Self {
33            max_frame_bytes: 16 * 1024 * 1024,
34            max_flashblocks_per_payload: 64,
35            max_transactions_per_payload: 50_000,
36            max_logs_per_payload: 200_000,
37        }
38    }
39}
40
41impl RawJsonFlashblocksLimits {
42    fn validate(self) -> Result<(), RawJsonFlashblocksError> {
43        if self.max_frame_bytes == 0 {
44            return Err(RawJsonFlashblocksError::InvalidLimits(
45                "max_frame_bytes must be greater than zero",
46            ));
47        }
48        if self.max_flashblocks_per_payload == 0 {
49            return Err(RawJsonFlashblocksError::InvalidLimits(
50                "max_flashblocks_per_payload must be greater than zero",
51            ));
52        }
53        if self.max_transactions_per_payload == 0 {
54            return Err(RawJsonFlashblocksError::InvalidLimits(
55                "max_transactions_per_payload must be greater than zero",
56            ));
57        }
58        if self.max_logs_per_payload == 0 {
59            return Err(RawJsonFlashblocksError::InvalidLimits(
60                "max_logs_per_payload must be greater than zero",
61            ));
62        }
63        Ok(())
64    }
65}
66
67/// One provider-provenanced cumulative preview plus the logs added by its
68/// latest indexed delta.
69#[derive(Clone, Debug, PartialEq, Eq)]
70#[non_exhaustive]
71pub struct FlashblockSnapshot {
72    /// Identity and cumulative transaction membership for the preview.
73    pub flashblock: FlashblockRef,
74    /// Structured logs added by this exact indexed delta.
75    pub logs: Vec<Log>,
76}
77
78/// Why a speculative Flashblock payload generation was revoked.
79#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
80#[non_exhaustive]
81pub enum FlashblockInvalidationReason {
82    /// The source skipped at least one index within a payload generation.
83    IndexGap,
84    /// A repeated index carried different content.
85    ConflictingDuplicate,
86    /// A generation began after index zero, so its base header was unavailable.
87    MissingInitialIndex,
88    /// The caller replaced or disconnected the externally managed source.
89    SourceReset,
90}
91
92/// Observable fail-closed invalidation for one provider generation.
93#[derive(Clone, Debug, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct FlashblockInvalidation {
96    /// Provider generation whose speculative payload was revoked.
97    pub provider: ProviderRef,
98    /// Payload identity that was active or could not be trusted.
99    pub payload_id: FixedBytes<8>,
100    /// Continuity or caller lifecycle transition that caused the revocation.
101    pub reason: FlashblockInvalidationReason,
102}
103
104/// Standardized update accepted by the existing preconfirmation pipeline.
105#[derive(Clone, Debug, PartialEq, Eq)]
106#[non_exhaustive]
107pub enum FlashblockUpdate {
108    /// A cumulative preview and its newly added structured logs.
109    Snapshot(Box<FlashblockSnapshot>),
110    /// The active speculative provider generation must be discarded.
111    Invalidated(FlashblockInvalidation),
112}
113
114/// One normalized raw update paired with its caller-clock arrival.
115///
116/// The millisecond value belongs to the monotonic clock supplied to
117/// [`BufferedRawJsonFlashblocksAdapter::ingest_json_timed_at`]. Applications
118/// convert it back to their `Instant` domain before subscriber handoff.
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub struct TimedFlashblockUpdate {
121    update: FlashblockUpdate,
122    source_ingress_millis: u64,
123}
124
125impl TimedFlashblockUpdate {
126    const fn new(update: FlashblockUpdate, source_ingress_millis: u64) -> Self {
127        Self {
128            update,
129            source_ingress_millis,
130        }
131    }
132
133    /// Borrow the normalized update.
134    pub const fn update(&self) -> &FlashblockUpdate {
135        &self.update
136    }
137
138    /// Arrival in the caller-owned monotonic millisecond domain.
139    pub const fn source_ingress_millis(&self) -> u64 {
140        self.source_ingress_millis
141    }
142
143    /// Consume the timed value into its normalized update.
144    pub fn into_update(self) -> FlashblockUpdate {
145        self.update
146    }
147}
148
149impl FlashblockUpdate {
150    /// Provider generation carried by this standardized update.
151    pub const fn provider(&self) -> &ProviderRef {
152        match self {
153            Self::Snapshot(snapshot) => &snapshot.flashblock.provider,
154            Self::Invalidated(invalidation) => &invalidation.provider,
155        }
156    }
157}
158
159/// Failure to enqueue a standardized update into an attached subscriber.
160#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
161#[non_exhaustive]
162pub enum FlashblockUpdateChannelError {
163    /// The update names a different configured endpoint.
164    #[error("Flashblock update came from an unexpected provider endpoint")]
165    UnexpectedEndpoint,
166    /// A non-blocking send found the bounded channel at capacity.
167    #[error("Flashblock update channel is full")]
168    Full,
169    /// The subscriber no longer owns the receiving half of the channel.
170    #[error("Flashblock update channel is closed")]
171    Closed,
172    /// The subscriber consumed the update but rejected its integrity or local
173    /// resource requirements. The application must revoke the source
174    /// generation before continuing.
175    #[error("Flashblock update was rejected by the subscriber")]
176    Rejected,
177}
178
179/// Subscriber verdict for one non-blocking standardized update admission.
180///
181/// Awaiting this receipt distinguishes bounded-queue admission from actual
182/// subscriber validation. Dropping it does not cancel the queued update.
183#[derive(Debug)]
184#[must_use = "await wait() to observe the subscriber validation verdict"]
185pub struct FlashblockUpdateAcknowledgement {
186    receiver: oneshot::Receiver<Result<(), FlashblockUpdateChannelError>>,
187}
188
189impl FlashblockUpdateAcknowledgement {
190    /// Wait until the subscriber accepts or rejects the queued update.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`FlashblockUpdateChannelError::Rejected`] after subscriber
195    /// validation failure, or [`FlashblockUpdateChannelError::Closed`] when the
196    /// subscriber shuts down before producing a verdict.
197    pub async fn wait(self) -> Result<(), FlashblockUpdateChannelError> {
198        self.receiver
199            .await
200            .unwrap_or(Err(FlashblockUpdateChannelError::Closed))
201    }
202}
203
204pub(crate) struct QueuedFlashblockUpdate {
205    pub(crate) update: FlashblockUpdate,
206    pub(crate) timing: FlashblockIngressTiming,
207    pub(crate) acknowledgement: oneshot::Sender<Result<(), FlashblockUpdateChannelError>>,
208}
209
210impl QueuedFlashblockUpdate {
211    fn new(
212        update: FlashblockUpdate,
213        timing: FlashblockIngressTiming,
214    ) -> (Self, FlashblockUpdateAcknowledgement) {
215        let (acknowledgement, receiver) = oneshot::channel();
216        (
217            Self {
218                update,
219                timing,
220                acknowledgement,
221            },
222            FlashblockUpdateAcknowledgement { receiver },
223        )
224    }
225}
226
227/// Cloneable application handle for a subscriber-owned, bounded update queue.
228///
229/// This handle performs no network I/O and implements no retry policy. The
230/// application keeps it while the [`super::AlloySubscriber`] may be moved into
231/// another runtime owner, and sends only updates produced by a compatible
232/// adapter. Backpressure, reconnects, and source rotation remain application
233/// responsibilities.
234#[derive(Clone, Debug)]
235pub struct FlashblockUpdateSender {
236    provider: ProviderRef,
237    sender: mpsc::Sender<QueuedFlashblockUpdate>,
238}
239
240impl FlashblockUpdateSender {
241    pub(crate) const fn new(
242        provider: ProviderRef,
243        sender: mpsc::Sender<QueuedFlashblockUpdate>,
244    ) -> Self {
245        Self { provider, sender }
246    }
247
248    /// Configured endpoint and initial generation for this queue.
249    pub const fn provider(&self) -> &ProviderRef {
250        &self.provider
251    }
252
253    /// Await bounded queue capacity, enqueue one standardized update, and wait
254    /// for the subscriber's validation verdict.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`FlashblockUpdateChannelError::UnexpectedEndpoint`] before
259    /// enqueueing an update from another endpoint, or
260    /// [`FlashblockUpdateChannelError::Closed`] after subscriber shutdown.
261    /// [`FlashblockUpdateChannelError::Rejected`] means the subscriber consumed
262    /// the update but rejected its integrity or local resource requirements;
263    /// revoke and replace that source generation before continuing.
264    pub async fn send(&self, update: FlashblockUpdate) -> Result<(), FlashblockUpdateChannelError> {
265        self.send_with_ingress(update, FlashblockIngressTiming::new(Instant::now()))
266            .await
267    }
268
269    /// Enqueue an update with its original process-local typed source arrival.
270    ///
271    /// # Errors
272    ///
273    /// Returns the same endpoint, closure, and subscriber-rejection errors as
274    /// [`Self::send`].
275    pub async fn send_with_ingress(
276        &self,
277        update: FlashblockUpdate,
278        timing: FlashblockIngressTiming,
279    ) -> Result<(), FlashblockUpdateChannelError> {
280        self.validate_endpoint(&update)?;
281        let (queued, acknowledgement) = QueuedFlashblockUpdate::new(update, timing);
282        self.sender
283            .send(queued)
284            .await
285            .map_err(|_| FlashblockUpdateChannelError::Closed)?;
286        acknowledgement.wait().await
287    }
288
289    /// Enqueue one standardized update without waiting for capacity and return
290    /// a receipt for the subscriber's eventual validation verdict.
291    ///
292    /// # Errors
293    ///
294    /// In addition to endpoint and closure errors, returns
295    /// [`FlashblockUpdateChannelError::Full`] when the bounded queue has no
296    /// immediate capacity. A successful return proves only queue admission;
297    /// await [`FlashblockUpdateAcknowledgement::wait`] before treating the
298    /// update as accepted. The caller decides whether to wait, invalidate its
299    /// current generation, or reconnect the external source.
300    pub fn try_send(
301        &self,
302        update: FlashblockUpdate,
303    ) -> Result<FlashblockUpdateAcknowledgement, FlashblockUpdateChannelError> {
304        self.try_send_with_ingress(update, FlashblockIngressTiming::new(Instant::now()))
305    }
306
307    /// Non-blockingly enqueue an update with its original typed source arrival.
308    ///
309    /// # Errors
310    ///
311    /// Returns the same endpoint, capacity, and closure errors as
312    /// [`Self::try_send`].
313    pub fn try_send_with_ingress(
314        &self,
315        update: FlashblockUpdate,
316        timing: FlashblockIngressTiming,
317    ) -> Result<FlashblockUpdateAcknowledgement, FlashblockUpdateChannelError> {
318        self.validate_endpoint(&update)?;
319        let (queued, acknowledgement) = QueuedFlashblockUpdate::new(update, timing);
320        self.sender.try_send(queued).map_err(|error| match error {
321            mpsc::error::TrySendError::Full(_) => FlashblockUpdateChannelError::Full,
322            mpsc::error::TrySendError::Closed(_) => FlashblockUpdateChannelError::Closed,
323        })?;
324        Ok(acknowledgement)
325    }
326
327    fn validate_endpoint(
328        &self,
329        update: &FlashblockUpdate,
330    ) -> Result<(), FlashblockUpdateChannelError> {
331        if update.provider().endpoint != self.provider.endpoint {
332            return Err(FlashblockUpdateChannelError::UnexpectedEndpoint);
333        }
334        Ok(())
335    }
336}
337
338pub(crate) fn flashblock_update_channel(
339    provider: ProviderRef,
340    capacity: usize,
341) -> (
342    FlashblockUpdateSender,
343    mpsc::Receiver<QueuedFlashblockUpdate>,
344) {
345    let (sender, receiver) = mpsc::channel(capacity);
346    (FlashblockUpdateSender::new(provider, sender), receiver)
347}
348
349/// A malformed, unsupported, or resource-exhausting raw JSON Flashblocks frame.
350#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
351#[non_exhaustive]
352pub enum RawJsonFlashblocksError {
353    /// Adapter bounds are unusable.
354    #[error("invalid raw JSON Flashblocks limits: {0}")]
355    InvalidLimits(&'static str),
356    /// A caller attempted an ambiguous or regressing source transition.
357    #[error("invalid raw JSON Flashblocks source transition: {0}")]
358    InvalidSourceTransition(&'static str),
359    /// The frame exceeded the configured byte bound.
360    #[error("raw JSON Flashblocks frame exceeds the configured byte limit")]
361    FrameTooLarge,
362    /// The JSON document did not match the supported receipt-enriched profile.
363    #[error("invalid raw JSON Flashblocks payload: {0}")]
364    InvalidPayload(String),
365    /// A configured per-payload resource bound was exceeded.
366    #[error("raw JSON Flashblocks payload exceeds the configured {0} limit")]
367    ResourceExhausted(&'static str),
368}
369
370/// Stateful converter for receipt-enriched, indexed JSON Flashblock payloads.
371///
372/// Compatibility is defined by the accepted wire profile, not by chain id:
373/// `payload_id`, a monotonically increasing `index`, an index-zero `base` (or
374/// `static`) header, transaction deltas in `diff.transactions`, and an exact
375/// receipt map in `metadata.receipts`. Provider JSON-RPC subscription envelopes,
376/// receipt-less payloads, and binary SSZ frames are separate wire profiles and
377/// are not accepted by this adapter.
378///
379/// The adapter performs no I/O. The caller owns WebSocket control frames,
380/// authentication, timeouts, retry, backoff, and provider rotation. On source
381/// replacement or disconnect, call [`Self::reset`] and forward the returned
382/// invalidation before accepting updates from the new provider generation.
383#[derive(Clone, Debug)]
384pub struct RawJsonFlashblocksAdapter {
385    provider: ProviderRef,
386    limits: RawJsonFlashblocksLimits,
387    active: Option<RawPayloadState>,
388    ignored_payload: Option<FixedBytes<8>>,
389}
390
391impl RawJsonFlashblocksAdapter {
392    /// Construct an adapter with bounded production defaults.
393    pub fn new(provider: ProviderRef) -> Self {
394        Self {
395            provider,
396            limits: RawJsonFlashblocksLimits::default(),
397            active: None,
398            ignored_payload: None,
399        }
400    }
401
402    /// Construct an adapter with explicit resource bounds.
403    pub fn with_limits(
404        provider: ProviderRef,
405        limits: RawJsonFlashblocksLimits,
406    ) -> Result<Self, RawJsonFlashblocksError> {
407        limits.validate()?;
408        Ok(Self {
409            provider,
410            limits,
411            active: None,
412            ignored_payload: None,
413        })
414    }
415
416    /// Provider generation attached to newly normalized snapshots.
417    pub const fn provider(&self) -> &ProviderRef {
418        &self.provider
419    }
420
421    /// Resource limits applied by this adapter.
422    pub const fn limits(&self) -> RawJsonFlashblocksLimits {
423        self.limits
424    }
425
426    /// Revoke the active payload and begin a caller-managed provider generation.
427    ///
428    /// This method never reconnects, sleeps, or performs I/O. `None` means the
429    /// prior source had no active payload to revoke.
430    ///
431    /// # Errors
432    ///
433    /// Returns [`RawJsonFlashblocksError::InvalidSourceTransition`] when the
434    /// endpoint identity changes or the generation does not increase. Rebuild
435    /// the adapter and subscriber binding to select a different endpoint.
436    pub fn reset(
437        &mut self,
438        provider: ProviderRef,
439    ) -> Result<Option<FlashblockUpdate>, RawJsonFlashblocksError> {
440        if provider.endpoint != self.provider.endpoint {
441            return Err(RawJsonFlashblocksError::InvalidSourceTransition(
442                "reset cannot change the configured endpoint identity",
443            ));
444        }
445        if provider.generation <= self.provider.generation {
446            return Err(RawJsonFlashblocksError::InvalidSourceTransition(
447                "reset requires a strictly newer provider generation",
448            ));
449        }
450        let invalidation = self.active.take().map(|active| {
451            FlashblockUpdate::Invalidated(FlashblockInvalidation {
452                provider: self.provider.clone(),
453                payload_id: active.payload_id,
454                reason: FlashblockInvalidationReason::SourceReset,
455            })
456        });
457        self.provider = provider;
458        self.ignored_payload = None;
459        Ok(invalidation)
460    }
461
462    /// Decode and normalize one raw JSON application-data frame.
463    ///
464    /// `Ok(None)` denotes an identical duplicate or a later delta from a
465    /// generation already invalidated for continuity loss. `Err` leaves the
466    /// last successfully published adapter state unchanged. A live caller that
467    /// cannot prove an application-data error irrelevant must call
468    /// [`Self::reset`] with a newer generation and forward the returned
469    /// invalidation before accepting more frames.
470    pub fn ingest_json(
471        &mut self,
472        frame: &[u8],
473    ) -> Result<Option<FlashblockUpdate>, RawJsonFlashblocksError> {
474        let payload = self.decode_json(frame)?;
475        self.ingest(payload)
476    }
477
478    fn decode_json(&self, frame: &[u8]) -> Result<RawFlashblockPayload, RawJsonFlashblocksError> {
479        if frame.len() > self.limits.max_frame_bytes {
480            return Err(RawJsonFlashblocksError::FrameTooLarge);
481        }
482        let payload: RawFlashblockPayload = serde_json::from_slice(frame)
483            .map_err(|error| RawJsonFlashblocksError::InvalidPayload(error.to_string()))?;
484        self.validate_index(payload.index)?;
485        Ok(payload)
486    }
487
488    fn validate_index(&self, index: u64) -> Result<(), RawJsonFlashblocksError> {
489        if usize::try_from(index)
490            .ok()
491            .is_none_or(|index| index >= self.limits.max_flashblocks_per_payload)
492        {
493            return Err(RawJsonFlashblocksError::ResourceExhausted(
494                "Flashblock index",
495            ));
496        }
497        Ok(())
498    }
499
500    fn ingest(
501        &mut self,
502        payload: RawFlashblockPayload,
503    ) -> Result<Option<FlashblockUpdate>, RawJsonFlashblocksError> {
504        self.validate_index(payload.index)?;
505        if self.ignored_payload == Some(payload.payload_id) {
506            return Ok(None);
507        }
508
509        let begins_new_payload = self
510            .active
511            .as_ref()
512            .is_none_or(|active| active.payload_id != payload.payload_id);
513        if begins_new_payload {
514            if payload.index != 0 {
515                let invalidated_payload = self
516                    .active
517                    .take()
518                    .map_or(payload.payload_id, |active| active.payload_id);
519                self.ignored_payload = Some(payload.payload_id);
520                return Ok(Some(self.invalidation(
521                    invalidated_payload,
522                    FlashblockInvalidationReason::MissingInitialIndex,
523                )));
524            }
525            let base = payload.base.clone().ok_or_else(|| {
526                RawJsonFlashblocksError::InvalidPayload("index zero omitted its base header".into())
527            })?;
528            if let Some(metadata_number) = payload.metadata.block_number
529                && metadata_number != base.block_number
530            {
531                return Err(RawJsonFlashblocksError::InvalidPayload(
532                    "base and metadata block numbers disagree".into(),
533                ));
534            }
535            let previous_active = self.active.take();
536            let previous_ignored_payload = self.ignored_payload.take();
537            self.active = Some(RawPayloadState {
538                payload_id: payload.payload_id,
539                base,
540                last_index: None,
541                cumulative_transactions: Vec::new(),
542                transaction_set: HashSet::new(),
543                next_log_index: 0,
544                cumulative_logs: 0,
545                index_commitments: HashMap::new(),
546            });
547            let result = self.ingest_active(payload);
548            if result.is_err() {
549                self.active = previous_active;
550                self.ignored_payload = previous_ignored_payload;
551            }
552            return result;
553        }
554
555        self.ingest_active(payload)
556    }
557
558    fn ingest_active(
559        &mut self,
560        payload: RawFlashblockPayload,
561    ) -> Result<Option<FlashblockUpdate>, RawJsonFlashblocksError> {
562        let active = self.active.as_mut().expect("new payload initialized above");
563        if payload
564            .metadata
565            .block_number
566            .is_some_and(|number| number != active.base.block_number)
567            || payload
568                .base
569                .as_ref()
570                .is_some_and(|base| base != &active.base)
571        {
572            return Err(RawJsonFlashblocksError::InvalidPayload(
573                "base header or metadata block numbers disagree with the active payload".into(),
574            ));
575        }
576        let delta_transactions = flashblock_transaction_hashes(&payload.diff.transactions)
577            .map_err(|error| RawJsonFlashblocksError::InvalidPayload(error.to_string()))?;
578        let receipt_hashes = payload
579            .metadata
580            .receipts
581            .keys()
582            .copied()
583            .collect::<HashSet<_>>();
584        let transaction_hashes = delta_transactions.iter().copied().collect::<HashSet<_>>();
585        if transaction_hashes.len() != delta_transactions.len() {
586            return Err(RawJsonFlashblocksError::InvalidPayload(
587                "the transaction delta contains a duplicate hash".into(),
588            ));
589        }
590        if receipt_hashes != transaction_hashes {
591            return Err(RawJsonFlashblocksError::InvalidPayload(
592                "receipt-map membership disagrees with the transaction delta".into(),
593            ));
594        }
595        let commitment = raw_payload_commitment(&payload, &delta_transactions);
596        if let Some(previous) = active.index_commitments.get(&payload.index) {
597            if *previous == commitment {
598                return Ok(None);
599            }
600            let payload_id = active.payload_id;
601            self.active = None;
602            self.ignored_payload = Some(payload_id);
603            return Ok(Some(self.invalidation(
604                payload_id,
605                FlashblockInvalidationReason::ConflictingDuplicate,
606            )));
607        }
608        let expected_index = active.last_index.map_or(0, |index| index.saturating_add(1));
609        if payload.index != expected_index {
610            let payload_id = active.payload_id;
611            self.active = None;
612            self.ignored_payload = Some(payload_id);
613            return Ok(Some(
614                self.invalidation(payload_id, FlashblockInvalidationReason::IndexGap),
615            ));
616        }
617
618        if active
619            .cumulative_transactions
620            .len()
621            .saturating_add(delta_transactions.len())
622            > self.limits.max_transactions_per_payload
623        {
624            return Err(RawJsonFlashblocksError::ResourceExhausted(
625                "transaction count",
626            ));
627        }
628        if delta_transactions
629            .iter()
630            .any(|hash| active.transaction_set.contains(hash))
631        {
632            return Err(RawJsonFlashblocksError::InvalidPayload(
633                "a transaction appeared in more than one indexed delta".into(),
634            ));
635        }
636
637        let transaction_offset = active.cumulative_transactions.len();
638        let mut logs = Vec::new();
639        for (delta_index, transaction_hash) in delta_transactions.iter().enumerate() {
640            let receipt = payload
641                .metadata
642                .receipts
643                .get(transaction_hash)
644                .expect("receipt membership checked above");
645            let transaction_index =
646                u64::try_from(transaction_offset.saturating_add(delta_index))
647                    .map_err(|_| RawJsonFlashblocksError::ResourceExhausted("transaction index"))?;
648            for raw_log in &receipt.logs {
649                if active.cumulative_logs.saturating_add(logs.len())
650                    >= self.limits.max_logs_per_payload
651                {
652                    return Err(RawJsonFlashblocksError::ResourceExhausted("log count"));
653                }
654                let inner = PrimitiveLog::new(
655                    raw_log.address,
656                    raw_log.topics.clone(),
657                    raw_log.data.clone(),
658                )
659                .ok_or_else(|| {
660                    RawJsonFlashblocksError::InvalidPayload(
661                        "receipt log contains more than four topics".into(),
662                    )
663                })?;
664                let log_index = active
665                    .next_log_index
666                    .checked_add(
667                        u64::try_from(logs.len())
668                            .map_err(|_| RawJsonFlashblocksError::ResourceExhausted("log index"))?,
669                    )
670                    .ok_or(RawJsonFlashblocksError::ResourceExhausted("log index"))?;
671                logs.push(Log {
672                    inner,
673                    block_hash: None,
674                    block_number: Some(active.base.block_number),
675                    block_timestamp: Some(active.base.timestamp),
676                    transaction_hash: Some(*transaction_hash),
677                    transaction_index: Some(transaction_index),
678                    log_index: Some(log_index),
679                    removed: false,
680                });
681            }
682        }
683
684        let mut cumulative_transactions = active.cumulative_transactions.clone();
685        cumulative_transactions.extend(delta_transactions.iter().copied());
686        let partial_block_hash = non_placeholder_hash(payload.diff.block_hash);
687        let state_root = non_placeholder_hash(payload.diff.state_root);
688        let transactions_root = payload
689            .diff
690            .transactions_root
691            .and_then(non_placeholder_hash);
692        let parent_hash = non_placeholder_hash(active.base.parent_hash);
693        let prevrandao = active.base.prevrandao.and_then(non_placeholder_hash);
694        let content_hash = flashblock_content_hash(FlashblockContentCommitment {
695            provider: &self.provider,
696            payload_id: Some(payload.payload_id),
697            index: Some(payload.index),
698            block_number: active.base.block_number,
699            partial_block_hash,
700            parent_hash,
701            state_root,
702            transactions_root,
703            transaction_hashes: &cumulative_transactions,
704            timestamp: Some(active.base.timestamp),
705            base_fee_per_gas: active.base.base_fee_per_gas,
706            beneficiary: active.base.beneficiary,
707            prevrandao,
708            gas_limit: active.base.gas_limit,
709        });
710        for log in &mut logs {
711            log.block_hash = Some(content_hash);
712        }
713        let flashblock = FlashblockRef {
714            provider: self.provider.clone(),
715            payload_id: Some(payload.payload_id),
716            index: Some(payload.index),
717            block_number: active.base.block_number,
718            content_hash,
719            partial_block_hash,
720            parent_hash,
721            state_root,
722            transactions_root,
723            transaction_hashes: cumulative_transactions.clone(),
724            timestamp: Some(active.base.timestamp),
725            base_fee_per_gas: active.base.base_fee_per_gas,
726            beneficiary: active.base.beneficiary,
727            prevrandao,
728            gas_limit: active.base.gas_limit,
729        };
730        let next_log_index = active
731            .next_log_index
732            .checked_add(
733                u64::try_from(logs.len())
734                    .map_err(|_| RawJsonFlashblocksError::ResourceExhausted("log index"))?,
735            )
736            .ok_or(RawJsonFlashblocksError::ResourceExhausted("log index"))?;
737        active.transaction_set.extend(delta_transactions);
738        active.cumulative_transactions = cumulative_transactions;
739        active.last_index = Some(payload.index);
740        active.index_commitments.insert(payload.index, commitment);
741        active.next_log_index = next_log_index;
742        active.cumulative_logs = active.cumulative_logs.saturating_add(logs.len());
743
744        Ok(Some(FlashblockUpdate::Snapshot(Box::new(
745            FlashblockSnapshot { flashblock, logs },
746        ))))
747    }
748
749    fn invalidation(
750        &self,
751        payload_id: FixedBytes<8>,
752        reason: FlashblockInvalidationReason,
753    ) -> FlashblockUpdate {
754        FlashblockUpdate::Invalidated(FlashblockInvalidation {
755            provider: self.provider.clone(),
756            payload_id,
757            reason,
758        })
759    }
760
761    fn invalidate_active(
762        &mut self,
763        payload_id: FixedBytes<8>,
764        reason: FlashblockInvalidationReason,
765    ) -> FlashblockUpdate {
766        self.active = None;
767        self.ignored_payload = Some(payload_id);
768        self.invalidation(payload_id, reason)
769    }
770}
771
772const MIN_BUFFERED_GAP_MILLIS: u64 = 300;
773const MAX_BUFFERED_GAP_MILLIS: u64 = 500;
774
775/// Provider-free adapter that tolerates one briefly reordered JSON Flashblock.
776///
777/// This wrapper preserves [`RawJsonFlashblocksAdapter`]'s immediate behavior
778/// except for one narrow case: when an active payload receives exactly
779/// `expected_index + 1`, it retains that one parsed frame until the missing
780/// index arrives or the caller-owned deadline expires. It performs no I/O,
781/// starts no timer, mutates no canonical state, and grants no execution or
782/// trigger authority. Applications must schedule their own timer from
783/// [`Self::buffered_gap`] and call [`Self::expire_gap_at`].
784#[derive(Clone, Debug)]
785pub struct BufferedRawJsonFlashblocksAdapter {
786    inner: RawJsonFlashblocksAdapter,
787    gap_timeout_millis: u64,
788    buffered: Option<BufferedRawFlashblock>,
789}
790
791#[derive(Clone, Debug)]
792struct BufferedRawFlashblock {
793    payload: RawFlashblockPayload,
794    commitment: B256,
795    expected_index: u64,
796    source_ingress_millis: u64,
797    expires_at_millis: u64,
798}
799
800impl BufferedRawJsonFlashblocksAdapter {
801    /// Construct a bounded one-frame reorder adapter.
802    ///
803    /// `gap_timeout_millis` must be in the reviewed inclusive range
804    /// `300..=500`. The caller supplies timestamps from one monotonic clock.
805    ///
806    /// # Errors
807    ///
808    /// Returns [`RawJsonFlashblocksError::InvalidLimits`] for an unreviewed gap
809    /// timeout or invalid raw-frame resource limits.
810    pub fn new(
811        provider: ProviderRef,
812        limits: RawJsonFlashblocksLimits,
813        gap_timeout_millis: u64,
814    ) -> Result<Self, RawJsonFlashblocksError> {
815        if !(MIN_BUFFERED_GAP_MILLIS..=MAX_BUFFERED_GAP_MILLIS).contains(&gap_timeout_millis) {
816            return Err(RawJsonFlashblocksError::InvalidLimits(
817                "buffered gap timeout must be between 300 and 500 milliseconds",
818            ));
819        }
820        Ok(Self {
821            inner: RawJsonFlashblocksAdapter::with_limits(provider, limits)?,
822            gap_timeout_millis,
823            buffered: None,
824        })
825    }
826
827    /// Provider generation attached to normalized snapshots and invalidations.
828    pub const fn provider(&self) -> &ProviderRef {
829        self.inner.provider()
830    }
831
832    /// Resource limits applied to immediate and buffered frames.
833    pub const fn limits(&self) -> RawJsonFlashblocksLimits {
834        self.inner.limits()
835    }
836
837    /// Return `(missing_index, buffered_index, expires_at_millis)` when a
838    /// caller-owned gap timer is required.
839    pub fn buffered_gap(&self) -> Option<(u64, u64, u64)> {
840        self.buffered.as_ref().map(|buffered| {
841            (
842                buffered.expected_index,
843                buffered.payload.index,
844                buffered.expires_at_millis,
845            )
846        })
847    }
848
849    /// Decode one application-data frame at a caller-supplied monotonic time.
850    ///
851    /// The returned vector has at most two entries. Two snapshots are returned
852    /// only when the missing index and the retained next index are validated
853    /// atomically and drained in order. Errors preserve the last successfully
854    /// published state and any pending gap for explicit caller reset.
855    ///
856    /// # Errors
857    ///
858    /// Returns [`RawJsonFlashblocksError`] immediately for malformed,
859    /// unsupported, or resource-exhausting input.
860    pub fn ingest_json_at(
861        &mut self,
862        frame: &[u8],
863        now_millis: u64,
864    ) -> Result<Vec<FlashblockUpdate>, RawJsonFlashblocksError> {
865        self.ingest_json_timed_at(frame, now_millis).map(|updates| {
866            updates
867                .into_iter()
868                .map(TimedFlashblockUpdate::into_update)
869                .collect()
870        })
871    }
872
873    /// Decode one application-data frame while retaining the original
874    /// caller-clock arrival for every emitted update.
875    ///
876    /// When a future frame is buffered across a one-index gap, its eventual
877    /// output retains the timestamp from the call that first supplied that
878    /// frame, not the later gap-closing call. This method is provider-free and
879    /// starts no timer.
880    ///
881    /// # Errors
882    ///
883    /// Returns [`RawJsonFlashblocksError`] under the same conditions as
884    /// [`Self::ingest_json_at`].
885    pub fn ingest_json_timed_at(
886        &mut self,
887        frame: &[u8],
888        now_millis: u64,
889    ) -> Result<Vec<TimedFlashblockUpdate>, RawJsonFlashblocksError> {
890        let payload = self.inner.decode_json(frame)?;
891        let mut updates = Vec::with_capacity(2);
892
893        if self
894            .buffered
895            .as_ref()
896            .is_some_and(|buffered| now_millis >= buffered.expires_at_millis)
897        {
898            let expired = self.buffered.as_ref().expect("checked above");
899            if payload.payload_id == expired.payload.payload_id {
900                let payload_id = expired.payload.payload_id;
901                self.buffered = None;
902                updates.push(TimedFlashblockUpdate::new(
903                    self.inner
904                        .invalidate_active(payload_id, FlashblockInvalidationReason::IndexGap),
905                    now_millis,
906                ));
907                return Ok(updates);
908            }
909
910            // Validate the replacement on a clone before publishing the expiry.
911            // An application-data error therefore preserves the observable
912            // invalidation and the pending timer for an explicit retry/reset.
913            let payload_id = expired.payload.payload_id;
914            let invalidation = self
915                .inner
916                .invalidation(payload_id, FlashblockInvalidationReason::IndexGap);
917            let mut staged = self.inner.clone();
918            let replacement = staged.ingest(payload)?;
919            self.inner = staged;
920            self.buffered = None;
921            updates.push(TimedFlashblockUpdate::new(invalidation, now_millis));
922            if let Some(update) = replacement {
923                updates.push(TimedFlashblockUpdate::new(update, now_millis));
924            }
925            return Ok(updates);
926        }
927
928        if let Some(buffered) = self.buffered.as_ref() {
929            if payload.payload_id == buffered.payload.payload_id {
930                if payload.index == buffered.expected_index {
931                    let buffered = self.buffered.as_ref().expect("checked above").clone();
932                    let mut staged = self.inner.clone();
933                    if let Some(update) = staged.ingest(payload)? {
934                        updates.push(TimedFlashblockUpdate::new(update, now_millis));
935                    }
936                    if let Some(update) = staged.ingest(buffered.payload)? {
937                        updates.push(TimedFlashblockUpdate::new(
938                            update,
939                            buffered.source_ingress_millis,
940                        ));
941                    }
942                    self.inner = staged;
943                    self.buffered = None;
944                    return Ok(updates);
945                }
946
947                if payload.index == buffered.payload.index {
948                    let commitment = self.validate_bufferable_payload(&payload)?;
949                    if commitment == buffered.commitment {
950                        return Ok(updates);
951                    }
952                    let payload_id = payload.payload_id;
953                    self.buffered = None;
954                    updates.push(TimedFlashblockUpdate::new(
955                        self.inner.invalidate_active(
956                            payload_id,
957                            FlashblockInvalidationReason::ConflictingDuplicate,
958                        ),
959                        now_millis,
960                    ));
961                    return Ok(updates);
962                }
963
964                if payload.index > buffered.payload.index {
965                    self.validate_bufferable_payload(&payload)?;
966                    let payload_id = payload.payload_id;
967                    self.buffered = None;
968                    updates.push(TimedFlashblockUpdate::new(
969                        self.inner
970                            .invalidate_active(payload_id, FlashblockInvalidationReason::IndexGap),
971                        now_millis,
972                    ));
973                    return Ok(updates);
974                }
975            } else {
976                // A rejected replacement must preserve the unresolved buffer.
977                let mut staged = self.inner.clone();
978                let replacement = staged.ingest(payload)?;
979                self.inner = staged;
980                self.buffered = None;
981                if let Some(update) = replacement {
982                    updates.push(TimedFlashblockUpdate::new(update, now_millis));
983                }
984                return Ok(updates);
985            }
986        }
987
988        if self.can_buffer_one_gap(&payload) {
989            let commitment = self.validate_bufferable_payload(&payload)?;
990            let active = self
991                .inner
992                .active
993                .as_ref()
994                .expect("buffering requires active state");
995            let expected_index = active.last_index.map_or(0, |index| index.saturating_add(1));
996            self.buffered = Some(BufferedRawFlashblock {
997                payload,
998                commitment,
999                expected_index,
1000                source_ingress_millis: now_millis,
1001                expires_at_millis: now_millis.saturating_add(self.gap_timeout_millis),
1002            });
1003            return Ok(updates);
1004        }
1005
1006        if self.is_more_than_one_index_ahead(&payload) {
1007            self.validate_bufferable_payload(&payload)?;
1008        }
1009
1010        if let Some(update) = self.inner.ingest(payload)? {
1011            if matches!(update, FlashblockUpdate::Invalidated(_)) {
1012                self.buffered = None;
1013            }
1014            updates.push(TimedFlashblockUpdate::new(update, now_millis));
1015        }
1016        Ok(updates)
1017    }
1018
1019    /// Expire a buffered gap using the same caller-owned monotonic clock.
1020    ///
1021    /// At or after the deadline this emits one typed `IndexGap` invalidation,
1022    /// clears the retained frame, and ignores the late remainder of that
1023    /// payload until a new payload begins.
1024    pub fn expire_gap_at(&mut self, now_millis: u64) -> Option<FlashblockUpdate> {
1025        let expired = self
1026            .buffered
1027            .as_ref()
1028            .is_some_and(|buffered| now_millis >= buffered.expires_at_millis);
1029        if !expired {
1030            return None;
1031        }
1032        let buffered = self.buffered.take().expect("checked above");
1033        Some(self.inner.invalidate_active(
1034            buffered.payload.payload_id,
1035            FlashblockInvalidationReason::IndexGap,
1036        ))
1037    }
1038
1039    /// Revoke the active payload and clear any retained reorder frame.
1040    ///
1041    /// A rejected source transition preserves both the active state and buffer.
1042    ///
1043    /// # Errors
1044    ///
1045    /// Returns [`RawJsonFlashblocksError::InvalidSourceTransition`] under the
1046    /// same conditions as [`RawJsonFlashblocksAdapter::reset`].
1047    pub fn reset(
1048        &mut self,
1049        provider: ProviderRef,
1050    ) -> Result<Option<FlashblockUpdate>, RawJsonFlashblocksError> {
1051        let invalidation = self.inner.reset(provider)?;
1052        self.buffered = None;
1053        Ok(invalidation)
1054    }
1055
1056    fn can_buffer_one_gap(&self, payload: &RawFlashblockPayload) -> bool {
1057        let Some(active) = self.inner.active.as_ref() else {
1058            return false;
1059        };
1060        if active.payload_id != payload.payload_id {
1061            return false;
1062        }
1063        let expected = active.last_index.map_or(0, |index| index.saturating_add(1));
1064        payload.index == expected.saturating_add(1)
1065    }
1066
1067    fn is_more_than_one_index_ahead(&self, payload: &RawFlashblockPayload) -> bool {
1068        let Some(active) = self.inner.active.as_ref() else {
1069            return false;
1070        };
1071        if active.payload_id != payload.payload_id {
1072            return false;
1073        }
1074        let expected = active.last_index.map_or(0, |index| index.saturating_add(1));
1075        payload.index > expected.saturating_add(1)
1076    }
1077
1078    fn validate_bufferable_payload(
1079        &self,
1080        payload: &RawFlashblockPayload,
1081    ) -> Result<B256, RawJsonFlashblocksError> {
1082        let active = self
1083            .inner
1084            .active
1085            .as_ref()
1086            .expect("buffer validation requires active state");
1087        if payload
1088            .metadata
1089            .block_number
1090            .is_some_and(|number| number != active.base.block_number)
1091            || payload
1092                .base
1093                .as_ref()
1094                .is_some_and(|base| base != &active.base)
1095        {
1096            return Err(RawJsonFlashblocksError::InvalidPayload(
1097                "base header or metadata block numbers disagree with the active payload".into(),
1098            ));
1099        }
1100        let transaction_hashes = flashblock_transaction_hashes(&payload.diff.transactions)
1101            .map_err(|error| RawJsonFlashblocksError::InvalidPayload(error.to_string()))?;
1102        let unique_transactions = transaction_hashes.iter().copied().collect::<HashSet<_>>();
1103        if unique_transactions.len() != transaction_hashes.len() {
1104            return Err(RawJsonFlashblocksError::InvalidPayload(
1105                "the transaction delta contains a duplicate hash".into(),
1106            ));
1107        }
1108        let receipt_hashes = payload
1109            .metadata
1110            .receipts
1111            .keys()
1112            .copied()
1113            .collect::<HashSet<_>>();
1114        if receipt_hashes != unique_transactions {
1115            return Err(RawJsonFlashblocksError::InvalidPayload(
1116                "receipt-map membership disagrees with the transaction delta".into(),
1117            ));
1118        }
1119        if active
1120            .cumulative_transactions
1121            .len()
1122            .saturating_add(transaction_hashes.len())
1123            > self.inner.limits.max_transactions_per_payload
1124        {
1125            return Err(RawJsonFlashblocksError::ResourceExhausted(
1126                "transaction count",
1127            ));
1128        }
1129        if transaction_hashes
1130            .iter()
1131            .any(|hash| active.transaction_set.contains(hash))
1132        {
1133            return Err(RawJsonFlashblocksError::InvalidPayload(
1134                "a transaction appeared in more than one indexed delta".into(),
1135            ));
1136        }
1137        let log_count =
1138            payload
1139                .metadata
1140                .receipts
1141                .values()
1142                .try_fold(0_usize, |count, receipt| {
1143                    for log in &receipt.logs {
1144                        if log.topics.len() > 4 {
1145                            return Err(RawJsonFlashblocksError::InvalidPayload(
1146                                "receipt log contains more than four topics".into(),
1147                            ));
1148                        }
1149                    }
1150                    count
1151                        .checked_add(receipt.logs.len())
1152                        .ok_or(RawJsonFlashblocksError::ResourceExhausted("log count"))
1153                })?;
1154        if active.cumulative_logs.saturating_add(log_count) > self.inner.limits.max_logs_per_payload
1155        {
1156            return Err(RawJsonFlashblocksError::ResourceExhausted("log count"));
1157        }
1158        u64::try_from(
1159            active
1160                .cumulative_transactions
1161                .len()
1162                .saturating_add(transaction_hashes.len()),
1163        )
1164        .map_err(|_| RawJsonFlashblocksError::ResourceExhausted("transaction index"))?;
1165        active
1166            .next_log_index
1167            .checked_add(
1168                u64::try_from(log_count)
1169                    .map_err(|_| RawJsonFlashblocksError::ResourceExhausted("log index"))?,
1170            )
1171            .ok_or(RawJsonFlashblocksError::ResourceExhausted("log index"))?;
1172        Ok(raw_payload_commitment(payload, &transaction_hashes))
1173    }
1174}
1175
1176fn raw_payload_commitment(payload: &RawFlashblockPayload, transaction_hashes: &[B256]) -> B256 {
1177    let mut commitment = Keccak256::new();
1178    commitment.update(b"evm-fork-cache/raw-json-flashblock/v1");
1179    commitment.update(payload.payload_id.as_slice());
1180    commitment.update(payload.index.to_be_bytes());
1181    match payload.base.as_ref() {
1182        Some(base) => {
1183            commitment.update([1]);
1184            commitment.update(base.parent_hash.as_slice());
1185            commitment.update(base.block_number.to_be_bytes());
1186            commitment.update(base.timestamp.to_be_bytes());
1187            commit_optional_raw_u64(&mut commitment, base.gas_limit);
1188            commit_optional_raw_u64(&mut commitment, base.base_fee_per_gas);
1189            commit_optional_raw_bytes(
1190                &mut commitment,
1191                base.beneficiary.as_ref().map(|address| address.as_slice()),
1192            );
1193            commit_optional_raw_bytes(
1194                &mut commitment,
1195                base.prevrandao.as_ref().map(B256::as_slice),
1196            );
1197        }
1198        None => commitment.update([0]),
1199    }
1200    commitment.update(payload.diff.state_root.as_slice());
1201    commitment.update(payload.diff.block_hash.as_slice());
1202    commit_optional_raw_bytes(
1203        &mut commitment,
1204        payload.diff.transactions_root.as_ref().map(B256::as_slice),
1205    );
1206    commit_optional_raw_u64(&mut commitment, payload.metadata.block_number);
1207    commitment.update((transaction_hashes.len() as u64).to_be_bytes());
1208    for transaction_hash in transaction_hashes {
1209        commitment.update(transaction_hash.as_slice());
1210        let receipt = payload
1211            .metadata
1212            .receipts
1213            .get(transaction_hash)
1214            .expect("receipt membership validated before commitment");
1215        commitment.update((receipt.logs.len() as u64).to_be_bytes());
1216        for log in &receipt.logs {
1217            commitment.update(log.address.as_slice());
1218            commitment.update((log.topics.len() as u64).to_be_bytes());
1219            for topic in &log.topics {
1220                commitment.update(topic.as_slice());
1221            }
1222            commitment.update((log.data.len() as u64).to_be_bytes());
1223            commitment.update(log.data.as_ref());
1224        }
1225    }
1226    commitment.finalize()
1227}
1228
1229fn commit_optional_raw_u64(commitment: &mut Keccak256, value: Option<u64>) {
1230    match value {
1231        Some(value) => {
1232            commitment.update([1]);
1233            commitment.update(value.to_be_bytes());
1234        }
1235        None => commitment.update([0]),
1236    }
1237}
1238
1239fn commit_optional_raw_bytes(commitment: &mut Keccak256, value: Option<&[u8]>) {
1240    match value {
1241        Some(value) => {
1242            commitment.update([1]);
1243            commitment.update((value.len() as u64).to_be_bytes());
1244            commitment.update(value);
1245        }
1246        None => commitment.update([0]),
1247    }
1248}
1249
1250#[derive(Clone, Debug)]
1251struct RawPayloadState {
1252    payload_id: FixedBytes<8>,
1253    base: BaseFlashblockBase,
1254    last_index: Option<u64>,
1255    cumulative_transactions: Vec<B256>,
1256    transaction_set: HashSet<B256>,
1257    next_log_index: u64,
1258    cumulative_logs: usize,
1259    index_commitments: HashMap<u64, B256>,
1260}
1261
1262#[derive(Clone, Debug, serde::Deserialize)]
1263struct RawFlashblockPayload {
1264    payload_id: FixedBytes<8>,
1265    index: u64,
1266    #[serde(default, alias = "static")]
1267    base: Option<BaseFlashblockBase>,
1268    diff: RawFlashblockDiff,
1269    metadata: RawFlashblockMetadata,
1270}
1271
1272#[derive(Clone, Debug, serde::Deserialize)]
1273struct RawFlashblockDiff {
1274    state_root: B256,
1275    block_hash: B256,
1276    #[serde(default)]
1277    transactions: Vec<serde_json::Value>,
1278    #[serde(default)]
1279    transactions_root: Option<B256>,
1280}
1281
1282#[derive(Clone, Debug, serde::Deserialize)]
1283struct RawFlashblockMetadata {
1284    #[serde(default, deserialize_with = "deserialize_optional_rpc_u64")]
1285    block_number: Option<u64>,
1286    #[serde(default, deserialize_with = "deserialize_receipts")]
1287    receipts: HashMap<B256, RawTransactionReceipt>,
1288}
1289
1290fn deserialize_receipts<'de, D>(
1291    deserializer: D,
1292) -> Result<HashMap<B256, RawTransactionReceipt>, D::Error>
1293where
1294    D: serde::Deserializer<'de>,
1295{
1296    struct ReceiptsVisitor;
1297
1298    impl<'de> serde::de::Visitor<'de> for ReceiptsVisitor {
1299        type Value = HashMap<B256, RawTransactionReceipt>;
1300
1301        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1302            formatter.write_str("a receipt map with unique transaction-hash keys")
1303        }
1304
1305        fn visit_map<A>(self, mut entries: A) -> Result<Self::Value, A::Error>
1306        where
1307            A: serde::de::MapAccess<'de>,
1308        {
1309            let mut receipts = HashMap::with_capacity(entries.size_hint().unwrap_or_default());
1310            while let Some((transaction_hash, receipt)) = entries.next_entry()? {
1311                if receipts.insert(transaction_hash, receipt).is_some() {
1312                    return Err(serde::de::Error::custom("duplicate receipt key"));
1313                }
1314            }
1315            Ok(receipts)
1316        }
1317    }
1318
1319    deserializer.deserialize_map(ReceiptsVisitor)
1320}
1321
1322#[derive(Clone, Debug, serde::Deserialize)]
1323struct RawTransactionReceipt {
1324    #[serde(default)]
1325    logs: Vec<RawReceiptLog>,
1326}
1327
1328#[derive(Clone, Debug, serde::Deserialize)]
1329struct RawReceiptLog {
1330    address: Address,
1331    #[serde(default)]
1332    topics: Vec<B256>,
1333    data: Bytes,
1334}