Skip to main content

evm_fork_cache/cache/
durable_checkpoint.rs

1//! Atomic, versioned checkpoints for event-maintained EVM state.
2//!
3//! The ordinary cache files are startup accelerators and may be flushed
4//! independently. A durable checkpoint has a stricter contract: cache state,
5//! canonical chain position, consumer identity, handler schema, and the last
6//! ingested subscriber token are serialized into one file and atomically
7//! replaced before the token may be acknowledged upstream. Files carry a
8//! Keccak integrity checksum and a configurable resource bound. The checksum
9//! detects accidental corruption; it is not authentication for an
10//! attacker-writable checkpoint path.
11
12use std::{
13    collections::HashMap,
14    fs::{self, File, OpenOptions},
15    io::{self, Read, Write},
16    path::{Component, Path, PathBuf},
17    sync::{
18        Arc, Mutex, OnceLock, Weak,
19        atomic::{AtomicU64, Ordering},
20    },
21};
22
23use alloy_eips::{BlockId, BlockNumberOrTag, RpcBlockHash};
24use alloy_primitives::{Address, B256, U256, keccak256};
25use foundry_fork_db::BlockchainDb;
26use revm::{database::Cache, primitives::hardfork::SpecId, state::AccountInfo};
27use serde::{Deserialize, Serialize};
28
29use super::{
30    BlockEnvSource, CodeSeedState, EvmCache, ImmutableDataCache, TrackedMapping, versioned,
31};
32
33const CHECKPOINT_MAGIC: &[u8; 8] = b"EFCCKPT\0";
34const CHECKPOINT_VERSION: u32 = 6;
35const CHECKPOINT_LABEL: &str = "durable reactive checkpoint";
36const CHECKPOINT_CHECKSUM_BYTES: usize = 32;
37const CHECKPOINT_HEADER_BYTES: u64 =
38    CHECKPOINT_MAGIC.len() as u64 + std::mem::size_of::<u32>() as u64;
39const MAX_TEMP_CREATE_ATTEMPTS: usize = 128;
40/// Default upper bound for a single durable checkpoint file (512 MiB).
41pub const DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES: u64 = 512 * 1024 * 1024;
42static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
43static CHECKPOINT_COORDINATORS: OnceLock<
44    Mutex<HashMap<PathBuf, Weak<CheckpointWriteCoordinator>>>,
45> = OnceLock::new();
46
47/// Stable identity of one durable cache consumer.
48///
49/// `subscriber_id` distinguishes independently acknowledged event sessions.
50/// `handler_set_id` is an application-owned schema/version fingerprint; change
51/// it whenever handler decoding or state semantics become incompatible with an
52/// older checkpoint.
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54#[non_exhaustive]
55pub struct DurableCheckpointIdentity {
56    /// Chain whose state is represented by the checkpoint.
57    pub chain_id: u64,
58    /// Stable event-subscriber or remote-session identity.
59    pub subscriber_id: String,
60    /// Stable application handler-set/schema identity.
61    pub handler_set_id: String,
62}
63
64impl DurableCheckpointIdentity {
65    /// Construct a durable consumer identity.
66    pub fn new(
67        chain_id: u64,
68        subscriber_id: impl Into<String>,
69        handler_set_id: impl Into<String>,
70    ) -> Self {
71        Self {
72            chain_id,
73            subscriber_id: subscriber_id.into(),
74            handler_set_id: handler_set_id.into(),
75        }
76    }
77}
78
79/// Canonical block committed by a durable checkpoint.
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81#[non_exhaustive]
82pub struct DurableCheckpointBlock {
83    /// Block number.
84    pub number: u64,
85    /// Canonical block hash. Applications must validate this against their RPC
86    /// source before restoring a non-finalized checkpoint.
87    pub hash: B256,
88    /// Parent hash, when the source supplied it.
89    pub parent_hash: Option<B256>,
90    /// Block timestamp, when the source supplied it.
91    pub timestamp: Option<u64>,
92}
93
94impl DurableCheckpointBlock {
95    /// Construct the minimum exact canonical identity required for restore.
96    pub const fn new(number: u64, hash: B256) -> Self {
97        Self {
98            number,
99            hash,
100            parent_hash: None,
101            timestamp: None,
102        }
103    }
104
105    /// Attach the canonical parent hash supplied by the source.
106    pub const fn with_parent_hash(mut self, parent_hash: B256) -> Self {
107        self.parent_hash = Some(parent_hash);
108        self
109    }
110
111    /// Attach the block timestamp supplied by the source.
112    pub const fn with_timestamp(mut self, timestamp: u64) -> Self {
113        self.timestamp = Some(timestamp);
114        self
115    }
116}
117
118/// Public commit metadata stored alongside the cache snapshot.
119#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
120#[non_exhaustive]
121pub struct DurableCheckpointMetadata {
122    /// Durable consumer identity guarded on restore.
123    pub identity: DurableCheckpointIdentity,
124    /// Last canonical block whose event effects are included.
125    pub block: DurableCheckpointBlock,
126    /// Last subscriber delivery token included in the snapshot.
127    ///
128    /// If the subscriber replays this token after an acknowledgement was lost,
129    /// the consumer can acknowledge it without applying the batch again.
130    pub delivery_token: Option<Vec<u8>>,
131    /// Core-computed witness for the exact delivery associated with
132    /// [`delivery_token`](Self::delivery_token).
133    ///
134    /// The witness is intentionally retained with its token when a later
135    /// tokenless barrier advances the overall checkpoint. On replay, the engine
136    /// requires the incoming delivery to reproduce this witness before it can
137    /// acknowledge the token without reapplying the batch. A token supplied by
138    /// low-level callers without a witness cannot use that replay shortcut.
139    pub delivery_witness: Option<B256>,
140    /// Opaque provider-specific resume state committed with this snapshot.
141    ///
142    /// The cache never interprets this value. A subscriber extension may use it
143    /// to resume from a native cursor after process restart.
144    pub subscriber_checkpoint: Option<Vec<u8>>,
145    /// Opaque core-runtime recovery state committed with the cache snapshot.
146    ///
147    /// The cache layer stores these bytes but does not interpret them. The
148    /// reactive engine uses them to restore finality and its bounded rollback
149    /// journal after restart.
150    pub runtime_checkpoint: Option<Vec<u8>>,
151}
152
153impl DurableCheckpointMetadata {
154    /// Construct checkpoint metadata.
155    pub fn new(identity: DurableCheckpointIdentity, block: DurableCheckpointBlock) -> Self {
156        Self {
157            identity,
158            block,
159            delivery_token: None,
160            delivery_witness: None,
161            subscriber_checkpoint: None,
162            runtime_checkpoint: None,
163        }
164    }
165
166    /// Attach the subscriber token whose effects are represented by this state.
167    pub fn with_delivery_token(mut self, delivery_token: impl Into<Vec<u8>>) -> Self {
168        self.delivery_token = Some(delivery_token.into());
169        self
170    }
171
172    /// Attach the core delivery witness associated with the delivery token.
173    ///
174    /// Most applications should let the reactive engine compute this value.
175    /// This builder exists for checkpoint migration and other
176    /// low-level integrations that reproduce the core witness contract exactly.
177    pub fn with_delivery_witness(mut self, delivery_witness: B256) -> Self {
178        self.delivery_witness = Some(delivery_witness);
179        self
180    }
181
182    /// Attach opaque provider-specific resume state represented by this state.
183    pub fn with_subscriber_checkpoint(mut self, checkpoint: impl Into<Vec<u8>>) -> Self {
184        self.subscriber_checkpoint = Some(checkpoint.into());
185        self
186    }
187
188    /// Attach opaque core-runtime recovery state represented by this snapshot.
189    pub fn with_runtime_checkpoint(mut self, checkpoint: impl Into<Vec<u8>>) -> Self {
190        self.runtime_checkpoint = Some(checkpoint.into());
191        self
192    }
193}
194
195/// Filesystem-backed durable checkpoint store.
196///
197/// Every store constructed for the same normalized path in one process shares
198/// writer generations, so a cancelled older async save cannot replace a newer
199/// request even when the callers did not clone the same store value. Deployments
200/// must still enforce one writer process per checkpoint path; filesystem rename
201/// atomicity does not establish ordering between independent processes. Atomic
202/// saves currently require Unix; unsupported platforms return a typed error
203/// rather than falling back to a remove-then-rename durability gap.
204#[derive(Clone, Debug)]
205pub struct DurableCheckpointStore {
206    path: PathBuf,
207    coordinator: Arc<CheckpointWriteCoordinator>,
208    max_checkpoint_bytes: u64,
209}
210
211#[derive(Debug, Default)]
212struct CheckpointWriteCoordinator {
213    latest_generation: AtomicU64,
214    writer: Mutex<()>,
215}
216
217impl PartialEq for DurableCheckpointStore {
218    fn eq(&self, other: &Self) -> bool {
219        self.path == other.path
220    }
221}
222
223impl Eq for DurableCheckpointStore {}
224
225impl DurableCheckpointStore {
226    /// Use `path` as the single atomic checkpoint file.
227    pub fn new(path: impl Into<PathBuf>) -> Self {
228        let path = normalized_checkpoint_path(&path.into());
229        Self {
230            coordinator: checkpoint_coordinator(&path),
231            path,
232            max_checkpoint_bytes: DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES,
233        }
234    }
235
236    /// Override the maximum encoded checkpoint size accepted for reads and
237    /// writes. The default is [`DEFAULT_MAX_DURABLE_CHECKPOINT_BYTES`].
238    ///
239    /// This bounds file reads and the encoded write allocation; it is not a
240    /// retention target. Snapshot capture still owns a clone of the cache state
241    /// before measuring its serialized size, so services must budget capture
242    /// memory separately. Lower the bound for tightly constrained services or
243    /// raise it deliberately for unusually large caches.
244    pub fn with_max_checkpoint_bytes(mut self, max_checkpoint_bytes: u64) -> Self {
245        self.max_checkpoint_bytes = max_checkpoint_bytes;
246        self
247    }
248
249    /// Maximum encoded checkpoint size accepted by this store.
250    pub fn max_checkpoint_bytes(&self) -> u64 {
251        self.max_checkpoint_bytes
252    }
253
254    /// Path of the checkpoint file.
255    pub fn path(&self) -> &Path {
256        &self.path
257    }
258
259    /// Persist a complete cache snapshot and its commit metadata atomically.
260    ///
261    /// The new file is written and synced in the same directory, renamed over
262    /// the previous checkpoint, and then the parent directory is synced. A
263    /// failure before the rename leaves the previous committed file intact.
264    /// The final rename replaces the destination directory entry itself; if the
265    /// destination is a symlink, the symlink is replaced rather than followed.
266    ///
267    /// This low-level API can verify the cache's chain id, but cannot prove that
268    /// its event-maintained state includes every effect through
269    /// `metadata.block`. It trusts that caller assertion and normalizes the
270    /// persisted exact pin/context to it. Production reactive consumers should
271    /// normally use `ReactiveEngine::*_checkpointed`, which derives metadata
272    /// from the batch committed with the runtime state.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`DurableCheckpointError`] when cache and metadata chain/context
277    /// identity disagree, the generation counter is exhausted, encoding exceeds
278    /// the configured size bound, or atomic write/sync/replace fails.
279    pub fn save(
280        &self,
281        cache: &EvmCache,
282        metadata: DurableCheckpointMetadata,
283    ) -> Result<(), DurableCheckpointError> {
284        validate_capture_identity(cache, &metadata)?;
285        // Request order is assigned before the potentially expensive snapshot
286        // capture. Otherwise an older large capture can finish after a newer
287        // small capture, reserve the later generation, and overwrite it.
288        let generation = self.reserve_generation()?;
289        let snapshot = DurableCheckpointSnapshot::capture(cache, metadata);
290        persist_snapshot(
291            &self.path,
292            snapshot,
293            &self.coordinator,
294            generation,
295            self.max_checkpoint_bytes,
296        )
297    }
298
299    /// Persist a complete cache snapshot without serializing or syncing the
300    /// checkpoint file on the async runtime worker.
301    ///
302    /// Capturing the owned cache snapshot is synchronous, but the potentially
303    /// long bincode encode, file write, fsync, rename, and directory fsync run
304    /// on Tokio's blocking pool. This keeps checkpoint durability out of event
305    /// transport and heartbeat scheduling paths.
306    /// The same low-level metadata trust contract as [`save`](Self::save)
307    /// applies.
308    ///
309    /// # Errors
310    ///
311    /// Returns [`DurableCheckpointError`] for the same identity, generation,
312    /// size, encoding, and filesystem failures as [`save`](Self::save), or when
313    /// Tokio's blocking task cannot be joined.
314    pub async fn save_async(
315        &self,
316        cache: &EvmCache,
317        metadata: DurableCheckpointMetadata,
318    ) -> Result<(), DurableCheckpointError> {
319        validate_capture_identity(cache, &metadata)?;
320        // See `save`: generation order is request-entry order, not
321        // capture-completion order. Capture is infallible after validation.
322        let generation = self.reserve_generation()?;
323        let snapshot = DurableCheckpointSnapshot::capture(cache, metadata);
324        let path = self.path.clone();
325        let coordinator = Arc::clone(&self.coordinator);
326        let max_checkpoint_bytes = self.max_checkpoint_bytes;
327        tokio::task::spawn_blocking(move || {
328            persist_snapshot(
329                &path,
330                snapshot,
331                &coordinator,
332                generation,
333                max_checkpoint_bytes,
334            )
335        })
336        .await
337        .map_err(DurableCheckpointError::TaskJoin)?
338    }
339
340    fn reserve_generation(&self) -> Result<u64, DurableCheckpointError> {
341        self.coordinator
342            .latest_generation
343            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |generation| {
344                generation.checked_add(1)
345            })
346            .map(|previous| previous + 1)
347            .map_err(|_| DurableCheckpointError::GenerationExhausted)
348    }
349
350    /// Load a checkpoint without mutating a cache.
351    ///
352    /// This split lets callers inspect and RPC-validate the canonical hash in
353    /// [`LoadedDurableCheckpoint::metadata`] before choosing to restore it.
354    /// The configured size ceiling and integrity checksum are verified before
355    /// any checkpoint payload is decoded.
356    ///
357    /// # Errors
358    ///
359    /// Returns [`DurableCheckpointError`] for read/metadata failures, oversized
360    /// files, invalid magic/version/encoding, checksum mismatch, or malformed
361    /// checkpoint content. A missing file returns `Ok(None)`.
362    pub fn load(&self) -> Result<Option<LoadedDurableCheckpoint>, DurableCheckpointError> {
363        let file = match File::open(&self.path) {
364            Ok(file) => file,
365            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
366            Err(source) => {
367                return Err(DurableCheckpointError::Read {
368                    path: self.path.clone(),
369                    source,
370                });
371            }
372        };
373        let reported_bytes = file
374            .metadata()
375            .map_err(|source| DurableCheckpointError::Read {
376                path: self.path.clone(),
377                source,
378            })?
379            .len();
380        if reported_bytes > self.max_checkpoint_bytes {
381            return Err(DurableCheckpointError::CheckpointTooLarge {
382                path: self.path.clone(),
383                bytes: reported_bytes,
384                max_bytes: self.max_checkpoint_bytes,
385            });
386        }
387        let mut data = Vec::new();
388        file.take(self.max_checkpoint_bytes.saturating_add(1))
389            .read_to_end(&mut data)
390            .map_err(|source| DurableCheckpointError::Read {
391                path: self.path.clone(),
392                source,
393            })?;
394        if data.len() as u64 > self.max_checkpoint_bytes {
395            return Err(DurableCheckpointError::CheckpointTooLarge {
396                path: self.path.clone(),
397                bytes: data.len() as u64,
398                max_bytes: self.max_checkpoint_bytes,
399            });
400        }
401        let Some(checksum_start) = data.len().checked_sub(CHECKPOINT_CHECKSUM_BYTES) else {
402            return Err(DurableCheckpointError::InvalidFormat {
403                path: self.path.clone(),
404            });
405        };
406        let encoded = &data[..checksum_start];
407        let expected = B256::from_slice(&data[checksum_start..]);
408        let actual = keccak256(encoded);
409        if actual != expected {
410            return Err(DurableCheckpointError::ChecksumMismatch {
411                path: self.path.clone(),
412            });
413        }
414        let snapshot = versioned::decode(
415            encoded,
416            CHECKPOINT_MAGIC,
417            CHECKPOINT_VERSION,
418            CHECKPOINT_LABEL,
419        )
420        .ok_or_else(|| DurableCheckpointError::InvalidFormat {
421            path: self.path.clone(),
422        })?;
423        Ok(Some(LoadedDurableCheckpoint { snapshot }))
424    }
425}
426
427fn checkpoint_coordinator(path: &Path) -> Arc<CheckpointWriteCoordinator> {
428    let key = normalized_checkpoint_path(path);
429    let coordinators = CHECKPOINT_COORDINATORS.get_or_init(|| Mutex::new(HashMap::new()));
430    let mut coordinators = coordinators
431        .lock()
432        .unwrap_or_else(std::sync::PoisonError::into_inner);
433    if let Some(coordinator) = coordinators.get(&key).and_then(Weak::upgrade) {
434        return coordinator;
435    }
436    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
437    let coordinator = Arc::new(CheckpointWriteCoordinator::default());
438    coordinators.insert(key, Arc::downgrade(&coordinator));
439    coordinator
440}
441
442fn normalized_checkpoint_path(path: &Path) -> PathBuf {
443    let absolute = if path.is_absolute() {
444        path.to_path_buf()
445    } else {
446        std::env::current_dir()
447            .map(|directory| directory.join(path))
448            .unwrap_or_else(|_| path.to_path_buf())
449    };
450
451    // Canonicalize the directory identity while deliberately preserving the
452    // destination entry itself. This makes aliases through symlinked parents
453    // share a writer coordinator, but an existing destination symlink is
454    // replaced by the atomic rename rather than followed to another file.
455    let Some(file_name) = absolute.file_name() else {
456        return normalize_existing_path_prefix(&absolute);
457    };
458    let parent = absolute.parent().unwrap_or_else(|| Path::new("."));
459    normalize_existing_path_prefix(parent).join(file_name)
460}
461
462fn normalize_existing_path_prefix(absolute: &Path) -> PathBuf {
463    // Resolve every existing prefix through the OS before normalizing a
464    // missing suffix. This preserves `symlink/..` semantics (which differ from
465    // blindly popping path components) while producing the same key before and
466    // after this store creates an ordinary missing directory suffix.
467    let components: Vec<_> = absolute.components().collect();
468    for split in (1..=components.len()).rev() {
469        let prefix: PathBuf = components[..split]
470            .iter()
471            .map(|component| component.as_os_str())
472            .collect();
473        let Ok(mut resolved) = prefix.canonicalize() else {
474            continue;
475        };
476        for component in &components[split..] {
477            match component {
478                Component::Prefix(prefix) => resolved.push(prefix.as_os_str()),
479                Component::RootDir => resolved.push(component.as_os_str()),
480                Component::CurDir => {}
481                Component::ParentDir => {
482                    let _ = resolved.pop();
483                }
484                Component::Normal(part) => resolved.push(part),
485            }
486        }
487        return resolved;
488    }
489
490    // Absolute roots normally make the loop succeed. Retain a deterministic
491    // fallback for unusual platforms/current-directory failures.
492    absolute.to_path_buf()
493}
494
495fn validate_capture_identity(
496    cache: &EvmCache,
497    metadata: &DurableCheckpointMetadata,
498) -> Result<(), DurableCheckpointError> {
499    if metadata.identity.chain_id != cache.chain_id {
500        return Err(DurableCheckpointError::CacheChainMismatch {
501            cache_chain_id: cache.chain_id,
502            checkpoint_chain_id: metadata.identity.chain_id,
503        });
504    }
505    Ok(())
506}
507
508fn persist_snapshot(
509    path: &Path,
510    snapshot: DurableCheckpointSnapshot,
511    coordinator: &CheckpointWriteCoordinator,
512    generation: u64,
513    max_checkpoint_bytes: u64,
514) -> Result<(), DurableCheckpointError> {
515    let _writer = coordinator
516        .writer
517        .lock()
518        .unwrap_or_else(std::sync::PoisonError::into_inner);
519    let latest = coordinator.latest_generation.load(Ordering::Acquire);
520    if generation != latest {
521        return Err(DurableCheckpointError::WriteSuperseded { generation, latest });
522    }
523    // Measure through bincode's size counter before allocating the encoded
524    // payload. The configured ceiling is a memory-safety boundary as well as a
525    // file-size boundary; checking only after `encode` would allocate the full
526    // checkpoint first.
527    let payload_bytes = bincode::serialized_size(&snapshot).map_err(|source| {
528        DurableCheckpointError::Encode(crate::errors::PersistenceError::serialize(
529            CHECKPOINT_LABEL,
530            source,
531        ))
532    })?;
533    let encoded_bytes = payload_bytes
534        .checked_add(CHECKPOINT_HEADER_BYTES)
535        .and_then(|bytes| bytes.checked_add(CHECKPOINT_CHECKSUM_BYTES as u64))
536        .ok_or(DurableCheckpointError::CheckpointSizeOverflow {
537            path: path.to_path_buf(),
538        })?;
539    if encoded_bytes > max_checkpoint_bytes {
540        return Err(DurableCheckpointError::CheckpointTooLarge {
541            path: path.to_path_buf(),
542            bytes: encoded_bytes,
543            max_bytes: max_checkpoint_bytes,
544        });
545    }
546    let mut data = versioned::encode(
547        CHECKPOINT_MAGIC,
548        CHECKPOINT_VERSION,
549        &snapshot,
550        CHECKPOINT_LABEL,
551    )
552    .map_err(DurableCheckpointError::Encode)?;
553    let checksum = keccak256(&data);
554    data.extend_from_slice(checksum.as_slice());
555    debug_assert_eq!(data.len() as u64, encoded_bytes);
556    atomic_replace(path, &data)
557}
558
559/// A decoded checkpoint awaiting identity/hash validation and restore.
560pub struct LoadedDurableCheckpoint {
561    snapshot: DurableCheckpointSnapshot,
562}
563
564impl LoadedDurableCheckpoint {
565    /// Inspect commit metadata before mutating the cache.
566    pub fn metadata(&self) -> &DurableCheckpointMetadata {
567        &self.snapshot.metadata
568    }
569
570    /// Restore the checkpoint into an already configured cache.
571    ///
572    /// The identity must match exactly and the cache must target the same chain.
573    /// Callers should validate `metadata().block.hash` against an authoritative
574    /// RPC source first when the checkpoint block is not finalized.
575    ///
576    /// # Errors
577    ///
578    /// Returns [`DurableCheckpointError::IdentityMismatch`] when `expected`
579    /// differs from stored metadata, or
580    /// [`DurableCheckpointError::CacheChainMismatch`] when the configured cache
581    /// targets another chain. The cache is not mutated on either error.
582    pub fn restore_into(
583        self,
584        cache: &mut EvmCache,
585        expected: &DurableCheckpointIdentity,
586    ) -> Result<DurableCheckpointMetadata, DurableCheckpointError> {
587        if &self.snapshot.metadata.identity != expected {
588            return Err(DurableCheckpointError::IdentityMismatch {
589                expected: expected.clone(),
590                actual: self.snapshot.metadata.identity.clone(),
591            });
592        }
593        if cache.chain_id != expected.chain_id {
594            return Err(DurableCheckpointError::CacheChainMismatch {
595                cache_chain_id: cache.chain_id,
596                checkpoint_chain_id: expected.chain_id,
597            });
598        }
599        Ok(self.snapshot.restore(cache))
600    }
601}
602
603#[derive(Serialize, Deserialize)]
604struct DurableCheckpointSnapshot {
605    metadata: DurableCheckpointMetadata,
606    state: EvmCacheStateSnapshot,
607}
608
609/// Complete mutable cache state used both by the on-disk checkpoint and by the
610/// checkpointed engine's in-process rollback guard.
611#[derive(Clone, Serialize, Deserialize)]
612pub(crate) struct EvmCacheStateSnapshot {
613    backend_accounts: Vec<(Address, AccountInfo)>,
614    backend_storage: Vec<(Address, Vec<(U256, U256)>)>,
615    backend_block_hashes: Vec<(U256, B256)>,
616    overlay: Cache,
617    token_decimals: HashMap<Address, u8>,
618    immutable_cache: ImmutableDataCache,
619    code_seeds: HashMap<Address, CodeSeedState>,
620    erc20_balance_slots: HashMap<Address, TrackedMapping>,
621    block: PersistedBlockId,
622    block_number: Option<u64>,
623    basefee: Option<u64>,
624    coinbase: Option<Address>,
625    prevrandao: Option<B256>,
626    block_gas_limit: Option<u64>,
627    timestamp_override: Option<u64>,
628    block_env_source: Option<BlockEnvSource>,
629    spec_id: SpecId,
630    snapshot_generation: u64,
631}
632
633#[derive(Clone, Copy, Serialize, Deserialize)]
634enum PersistedBlockId {
635    Hash {
636        hash: B256,
637        require_canonical: Option<bool>,
638    },
639    Latest,
640    Finalized,
641    Safe,
642    Earliest,
643    Pending,
644    Number(u64),
645}
646
647impl From<BlockId> for PersistedBlockId {
648    fn from(block: BlockId) -> Self {
649        match block {
650            BlockId::Hash(hash) => Self::Hash {
651                hash: hash.block_hash,
652                require_canonical: hash.require_canonical,
653            },
654            BlockId::Number(BlockNumberOrTag::Latest) => Self::Latest,
655            BlockId::Number(BlockNumberOrTag::Finalized) => Self::Finalized,
656            BlockId::Number(BlockNumberOrTag::Safe) => Self::Safe,
657            BlockId::Number(BlockNumberOrTag::Earliest) => Self::Earliest,
658            BlockId::Number(BlockNumberOrTag::Pending) => Self::Pending,
659            BlockId::Number(BlockNumberOrTag::Number(number)) => Self::Number(number),
660        }
661    }
662}
663
664impl From<PersistedBlockId> for BlockId {
665    fn from(block: PersistedBlockId) -> Self {
666        match block {
667            PersistedBlockId::Hash {
668                hash,
669                require_canonical,
670            } => BlockId::Hash(RpcBlockHash::from_hash(hash, require_canonical)),
671            PersistedBlockId::Latest => BlockId::latest(),
672            PersistedBlockId::Finalized => BlockId::finalized(),
673            PersistedBlockId::Safe => BlockId::safe(),
674            PersistedBlockId::Earliest => BlockId::earliest(),
675            PersistedBlockId::Pending => BlockId::pending(),
676            PersistedBlockId::Number(number) => BlockId::number(number),
677        }
678    }
679}
680
681impl DurableCheckpointSnapshot {
682    fn capture(cache: &EvmCache, metadata: DurableCheckpointMetadata) -> Self {
683        let mut state = EvmCacheStateSnapshot::capture(cache);
684        state.align_to_checkpoint_block(&metadata.block);
685        Self { metadata, state }
686    }
687
688    fn restore(self, cache: &mut EvmCache) -> DurableCheckpointMetadata {
689        let block_hash = self.metadata.block.hash;
690        self.state.restore(cache);
691
692        // Keep lazy RPC misses pinned to exactly the validated canonical block.
693        // A number-only pin could silently mix this snapshot with a replacement
694        // branch after a shallow reorg. Setting
695        // the backend pin directly avoids `set_block` clearing the restored EVM
696        // block context and incrementing the restored generation.
697        let block = alloy_eips::BlockId::from((block_hash, Some(true)));
698        cache.block = block;
699        let _ = cache.backend.set_pinned_block(block);
700
701        self.metadata
702    }
703}
704
705impl EvmCacheStateSnapshot {
706    pub(crate) fn capture(cache: &EvmCache) -> Self {
707        let (backend_accounts, backend_storage, backend_block_hashes) =
708            capture_backend_maps(&cache.blockchain_db);
709
710        Self {
711            backend_accounts,
712            backend_storage,
713            backend_block_hashes,
714            overlay: cache.db.cache.clone(),
715            token_decimals: cache.token_decimals.clone(),
716            immutable_cache: cache.immutable_cache.clone(),
717            code_seeds: cache.code_seeds.clone(),
718            erc20_balance_slots: cache.erc20_balance_slots.clone(),
719            block: cache.block.into(),
720            block_number: cache.block_number,
721            basefee: cache.basefee,
722            coinbase: cache.coinbase,
723            prevrandao: cache.prevrandao,
724            block_gas_limit: cache.block_gas_limit,
725            timestamp_override: cache.timestamp_override,
726            block_env_source: cache.block_env_source,
727            spec_id: cache.spec_id,
728            snapshot_generation: cache.snapshot_generation,
729        }
730    }
731
732    /// Make the persisted execution context internally agree with the
733    /// checkpoint's authoritative canonical coverage.
734    ///
735    /// Compact indexer progress can advance the checkpoint without delivering
736    /// a full header. In that case carrying an older cache `NUMBER`, timestamp,
737    /// or fee environment alongside the newer exact pin would create a split
738    /// state on restore. A full environment is retained only when cache
739    /// provenance proves it came from this exact number/hash and any supplied
740    /// checkpoint timestamp agrees. Otherwise number and timestamp are aligned
741    /// from metadata and unproven header-only fields are cleared.
742    fn align_to_checkpoint_block(&mut self, block: &DurableCheckpointBlock) {
743        let preserve_full_env = matches!(
744            self.block_env_source,
745            Some(BlockEnvSource::VerifiedHash { number, hash })
746                if number == block.number
747                    && hash == block.hash
748                    && block
749                        .timestamp
750                        .zip(self.timestamp_override)
751                        .is_none_or(|(expected, actual)| expected == actual)
752        );
753        self.block = PersistedBlockId::Hash {
754            hash: block.hash,
755            require_canonical: Some(true),
756        };
757        self.block_number = Some(block.number);
758        if !preserve_full_env {
759            self.timestamp_override = block.timestamp;
760            self.basefee = None;
761            self.coinbase = None;
762            self.prevrandao = None;
763            self.block_gas_limit = None;
764            self.block_env_source = None;
765        }
766    }
767
768    pub(crate) fn restore(self, cache: &mut EvmCache) {
769        {
770            let mut accounts = cache.blockchain_db.accounts().write();
771            accounts.clear();
772            accounts.extend(self.backend_accounts);
773        }
774        {
775            let mut storage = cache.blockchain_db.storage().write();
776            storage.clear();
777            storage.extend(
778                self.backend_storage
779                    .into_iter()
780                    .map(|(address, slots)| (address, slots.into_iter().collect())),
781            );
782        }
783        {
784            let mut hashes = cache.blockchain_db.block_hashes().write();
785            hashes.clear();
786            hashes.extend(self.backend_block_hashes);
787        }
788
789        cache.db.cache = self.overlay;
790        cache.token_decimals = self.token_decimals;
791        cache.immutable_cache = self.immutable_cache;
792        cache.code_seeds = self.code_seeds;
793        cache.erc20_balance_slots = self.erc20_balance_slots;
794        let block = BlockId::from(self.block);
795        cache.block = block;
796        let _ = cache.backend.set_pinned_block(block);
797        cache.block_number = self.block_number;
798        cache.basefee = self.basefee;
799        cache.coinbase = self.coinbase;
800        cache.prevrandao = self.prevrandao;
801        cache.block_gas_limit = self.block_gas_limit;
802        cache.timestamp_override = self.timestamp_override;
803        cache.block_env_source = self.block_env_source;
804        cache.spec_id = self.spec_id;
805        cache.snapshot_generation = self.snapshot_generation;
806        cache.base = None;
807        cache.base_dirty.clear();
808        cache.base_full_rebuild = true;
809        cache.base_storage_lens.clear();
810    }
811}
812
813type BackendMapsSnapshot = (
814    Vec<(Address, AccountInfo)>,
815    Vec<(Address, Vec<(U256, U256)>)>,
816    Vec<(U256, B256)>,
817);
818
819fn capture_backend_maps(blockchain_db: &BlockchainDb) -> BackendMapsSnapshot {
820    // Hold all three backend read guards together. `SharedBackend` applies
821    // queued account/storage/block-hash mutations through these same locks;
822    // retaining the earlier guards while later maps are cloned therefore gives
823    // the snapshot one coherent point-in-time prefix instead of an impossible
824    // old-account/new-storage mixture. The backend handler never holds more
825    // than one of these locks, so this stable order cannot form a cycle with
826    // normal lazy RPC population.
827    let accounts = blockchain_db.accounts().read();
828    let storage = blockchain_db.storage().read();
829    let block_hashes = blockchain_db.block_hashes().read();
830    let backend_accounts = accounts
831        .iter()
832        .map(|(address, info)| (*address, info.clone()))
833        .collect();
834    let backend_storage = storage
835        .iter()
836        .map(|(address, slots)| {
837            (
838                *address,
839                slots.iter().map(|(key, value)| (*key, *value)).collect(),
840            )
841        })
842        .collect();
843    let backend_block_hashes = block_hashes
844        .iter()
845        .map(|(number, hash)| (*number, *hash))
846        .collect();
847    (backend_accounts, backend_storage, backend_block_hashes)
848}
849
850#[cfg(test)]
851mod tests {
852    use std::{
853        fs,
854        sync::{Arc, Barrier},
855        thread,
856        time::{Duration, Instant},
857    };
858
859    use foundry_fork_db::{BlockchainDb, cache::BlockchainDbMeta};
860
861    use super::capture_backend_maps;
862    #[cfg(unix)]
863    use super::create_unique_temp_file;
864
865    #[test]
866    fn backend_capture_retains_earlier_guards_while_waiting_for_later_maps() {
867        let blockchain_db = Arc::new(BlockchainDb::new(BlockchainDbMeta::default(), None));
868        let storage_guard = blockchain_db.storage().write();
869        let start = Arc::new(Barrier::new(2));
870        let worker_db = Arc::clone(&blockchain_db);
871        let worker_start = Arc::clone(&start);
872        let capture = thread::spawn(move || {
873            worker_start.wait();
874            capture_backend_maps(&worker_db)
875        });
876        start.wait();
877
878        // The worker can acquire the accounts read guard, but must block on the
879        // storage write guard held above. A per-map clone that dropped the first
880        // guard would allow this write; coherent capture must keep rejecting it.
881        let deadline = Instant::now() + Duration::from_secs(2);
882        let retained_accounts_guard = loop {
883            if blockchain_db.accounts().try_write().is_none() {
884                break true;
885            }
886            if Instant::now() >= deadline {
887                break false;
888            }
889            thread::yield_now();
890        };
891        assert!(
892            retained_accounts_guard,
893            "capture must retain the accounts guard while awaiting storage"
894        );
895
896        drop(storage_guard);
897        capture.join().expect("capture thread");
898    }
899
900    #[cfg(unix)]
901    #[test]
902    fn stale_temp_candidate_is_skipped_without_blocking_checkpoint_progress() {
903        use std::os::unix::fs::PermissionsExt;
904
905        let root = std::env::temp_dir().join(format!(
906            "evm-fork-cache-stale-temp-{}-{}",
907            std::process::id(),
908            super::NEXT_TEMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
909        ));
910        fs::create_dir_all(&root).expect("create test directory");
911        let destination = root.join("checkpoint.bin");
912        let stale = root.join(".checkpoint.bin.first");
913        let fresh = root.join(".checkpoint.bin.second");
914        fs::write(&stale, b"stale crash residue").expect("precreate first candidate");
915        let mut candidates = [stale.clone(), fresh.clone()].into_iter();
916
917        let (selected, file) = create_unique_temp_file(&destination, || {
918            candidates.next().expect("bounded test candidates")
919        })
920        .expect("collision must retry with the next candidate");
921        drop(file);
922
923        assert_eq!(selected, fresh);
924        assert_eq!(
925            fs::metadata(&selected)
926                .expect("fresh temp metadata")
927                .permissions()
928                .mode()
929                & 0o777,
930            0o600,
931            "checkpoint temp files contain provider cursors and must be owner-only"
932        );
933        assert_eq!(
934            fs::read(&stale).expect("stale file remains"),
935            b"stale crash residue"
936        );
937        fs::remove_dir_all(root).expect("remove test directory");
938    }
939}
940
941#[cfg(unix)]
942fn atomic_replace(path: &Path, data: &[u8]) -> Result<(), DurableCheckpointError> {
943    let parent = path.parent().unwrap_or_else(|| Path::new("."));
944    fs::create_dir_all(parent).map_err(|source| DurableCheckpointError::CreateDir {
945        path: parent.to_path_buf(),
946        source,
947    })?;
948
949    let (temp_path, mut file) = create_unique_temp_file(path, || next_temp_path(path))?;
950    let result = (|| {
951        file.write_all(data)
952            .and_then(|()| file.sync_all())
953            .map_err(|source| DurableCheckpointError::Write {
954                path: temp_path.clone(),
955                source,
956            })?;
957        fs::rename(&temp_path, path).map_err(|source| DurableCheckpointError::Rename {
958            from: temp_path.clone(),
959            to: path.to_path_buf(),
960            source,
961        })?;
962        sync_parent_directory(parent)?;
963        Ok(())
964    })();
965
966    if result.is_err() {
967        let _ = fs::remove_file(&temp_path);
968    }
969    result
970}
971
972#[cfg(not(unix))]
973fn atomic_replace(path: &Path, _data: &[u8]) -> Result<(), DurableCheckpointError> {
974    Err(DurableCheckpointError::AtomicReplaceUnsupported {
975        path: path.to_path_buf(),
976    })
977}
978
979#[cfg(unix)]
980fn create_unique_temp_file(
981    destination: &Path,
982    mut next_candidate: impl FnMut() -> PathBuf,
983) -> Result<(PathBuf, File), DurableCheckpointError> {
984    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
985
986    for _ in 0..MAX_TEMP_CREATE_ATTEMPTS {
987        let candidate = next_candidate();
988        match OpenOptions::new()
989            .write(true)
990            .create_new(true)
991            .mode(0o600)
992            .open(&candidate)
993        {
994            Ok(file) => {
995                // `mode` prevents a permissive creation window; explicitly
996                // resetting permissions also makes the contract independent
997                // of an unusually restrictive process umask.
998                if let Err(source) = file.set_permissions(fs::Permissions::from_mode(0o600)) {
999                    drop(file);
1000                    let _ = fs::remove_file(&candidate);
1001                    return Err(DurableCheckpointError::Write {
1002                        path: candidate,
1003                        source,
1004                    });
1005                }
1006                return Ok((candidate, file));
1007            }
1008            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => continue,
1009            Err(source) => {
1010                return Err(DurableCheckpointError::Write {
1011                    path: candidate,
1012                    source,
1013                });
1014            }
1015        }
1016    }
1017    Err(DurableCheckpointError::TemporaryPathExhausted {
1018        path: destination.to_path_buf(),
1019        attempts: MAX_TEMP_CREATE_ATTEMPTS,
1020    })
1021}
1022
1023#[cfg(unix)]
1024fn sync_parent_directory(parent: &Path) -> Result<(), DurableCheckpointError> {
1025    File::open(parent)
1026        .and_then(|directory| directory.sync_all())
1027        .map_err(|source| DurableCheckpointError::SyncDirectory {
1028            path: parent.to_path_buf(),
1029            source,
1030        })
1031}
1032
1033#[cfg(not(unix))]
1034fn sync_parent_directory(_parent: &Path) -> Result<(), DurableCheckpointError> {
1035    // Rust has no portable directory-fsync primitive. The file itself has
1036    // already been synced before the atomic replace on these targets.
1037    Ok(())
1038}
1039
1040#[cfg(unix)]
1041fn next_temp_path(path: &Path) -> PathBuf {
1042    let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
1043    let name = path
1044        .file_name()
1045        .and_then(|name| name.to_str())
1046        .unwrap_or("checkpoint");
1047    path.with_file_name(format!(".{name}.tmp-{}-{id}", std::process::id()))
1048}
1049
1050/// Durable checkpoint persistence or compatibility failure.
1051#[derive(Debug, thiserror::Error)]
1052#[non_exhaustive]
1053pub enum DurableCheckpointError {
1054    /// This platform cannot provide the crate's required atomic replacement.
1055    #[error("atomic durable checkpoint replacement for {path:?} is unsupported on this platform")]
1056    AtomicReplaceUnsupported {
1057        /// Destination checkpoint path.
1058        path: PathBuf,
1059    },
1060    /// The checkpoint payload could not be serialized.
1061    #[error(transparent)]
1062    Encode(#[from] crate::errors::PersistenceError),
1063    /// The blocking checkpoint writer task was cancelled or panicked.
1064    #[error("durable checkpoint writer task failed: {0}")]
1065    TaskJoin(#[source] tokio::task::JoinError),
1066    /// The in-process writer generation counter cannot advance safely.
1067    #[error("durable checkpoint writer generation exhausted")]
1068    GenerationExhausted,
1069    /// A newer write was requested before this writer could install its snapshot.
1070    #[error(
1071        "durable checkpoint write generation {generation} was superseded by generation {latest}"
1072    )]
1073    WriteSuperseded {
1074        /// Generation assigned to this write.
1075        generation: u64,
1076        /// Latest generation requested from this store.
1077        latest: u64,
1078    },
1079    /// The checkpoint file could not be read.
1080    #[error("failed to read durable checkpoint {path:?}: {source}")]
1081    Read {
1082        /// Checkpoint path.
1083        path: PathBuf,
1084        /// Filesystem failure.
1085        #[source]
1086        source: io::Error,
1087    },
1088    /// The file does not carry the supported magic, version, or payload.
1089    #[error("durable checkpoint {path:?} has an invalid or unsupported format")]
1090    InvalidFormat {
1091        /// Checkpoint path.
1092        path: PathBuf,
1093    },
1094    /// The checkpoint checksum does not match its encoded contents.
1095    #[error("durable checkpoint {path:?} failed its integrity checksum")]
1096    ChecksumMismatch {
1097        /// Checkpoint path.
1098        path: PathBuf,
1099    },
1100    /// The checkpoint exceeds this store's configured resource bound.
1101    #[error(
1102        "durable checkpoint {path:?} is {bytes} bytes, exceeding the configured {max_bytes}-byte limit"
1103    )]
1104    CheckpointTooLarge {
1105        /// Checkpoint path.
1106        path: PathBuf,
1107        /// Encoded file size observed or produced.
1108        bytes: u64,
1109        /// Maximum encoded size accepted by the store.
1110        max_bytes: u64,
1111    },
1112    /// Encoded checkpoint size overflowed the supported `u64` accounting.
1113    #[error("durable checkpoint {path:?} size exceeds supported accounting")]
1114    CheckpointSizeOverflow {
1115        /// Checkpoint path.
1116        path: PathBuf,
1117    },
1118    /// The checkpoint directory could not be created.
1119    #[error("failed to create durable checkpoint directory {path:?}: {source}")]
1120    CreateDir {
1121        /// Directory path.
1122        path: PathBuf,
1123        /// Filesystem failure.
1124        #[source]
1125        source: io::Error,
1126    },
1127    /// The temporary checkpoint file could not be written or synced.
1128    #[error("failed to write durable checkpoint {path:?}: {source}")]
1129    Write {
1130        /// Temporary checkpoint path.
1131        path: PathBuf,
1132        /// Filesystem failure.
1133        #[source]
1134        source: io::Error,
1135    },
1136    /// Every bounded unique temporary-file candidate already existed.
1137    #[error(
1138        "failed to allocate a unique temporary file for durable checkpoint {path:?} after {attempts} attempts"
1139    )]
1140    TemporaryPathExhausted {
1141        /// Destination checkpoint path.
1142        path: PathBuf,
1143        /// Number of collision retries attempted.
1144        attempts: usize,
1145    },
1146    /// The synced temporary file could not be atomically installed.
1147    #[error("failed to replace durable checkpoint {to:?} from {from:?}: {source}")]
1148    Rename {
1149        /// Temporary checkpoint path.
1150        from: PathBuf,
1151        /// Destination checkpoint path.
1152        to: PathBuf,
1153        /// Filesystem failure.
1154        #[source]
1155        source: io::Error,
1156    },
1157    /// The checkpoint rename committed but its containing directory could not
1158    /// be synced. The file may exist, but the caller must not acknowledge the
1159    /// delivery because crash durability was not established.
1160    #[error("failed to sync durable checkpoint directory {path:?}: {source}")]
1161    SyncDirectory {
1162        /// Directory path.
1163        path: PathBuf,
1164        /// Filesystem failure.
1165        #[source]
1166        source: io::Error,
1167    },
1168    /// A checkpoint was opened under a different subscriber or handler schema.
1169    #[error("durable checkpoint identity mismatch: expected {expected:?}, found {actual:?}")]
1170    IdentityMismatch {
1171        /// Requested consumer identity.
1172        expected: DurableCheckpointIdentity,
1173        /// Stored consumer identity.
1174        actual: DurableCheckpointIdentity,
1175    },
1176    /// The configured cache and checkpoint identity target different chains.
1177    #[error(
1178        "durable checkpoint chain {checkpoint_chain_id} does not match cache chain {cache_chain_id}"
1179    )]
1180    CacheChainMismatch {
1181        /// Current cache chain id.
1182        cache_chain_id: u64,
1183        /// Stored/requested checkpoint chain id.
1184        checkpoint_chain_id: u64,
1185    },
1186}