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