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        // Explicit CAS preserves the 1.90 MSRV; newer toolchains deprecate
345        // fetch_update in favor of an API unavailable on that compiler.
346        let generation = &self.coordinator.latest_generation;
347        let mut observed = generation.load(Ordering::Acquire);
348        loop {
349            let next = observed
350                .checked_add(1)
351                .ok_or(DurableCheckpointError::GenerationExhausted)?;
352            match generation.compare_exchange_weak(
353                observed,
354                next,
355                Ordering::AcqRel,
356                Ordering::Acquire,
357            ) {
358                Ok(_) => return Ok(next),
359                Err(current) => observed = current,
360            }
361        }
362    }
363
364    /// Load a checkpoint without mutating a cache.
365    ///
366    /// This split lets callers inspect and RPC-validate the canonical hash in
367    /// [`LoadedDurableCheckpoint::metadata`] before choosing to restore it.
368    /// The configured size ceiling and integrity checksum are verified before
369    /// any checkpoint payload is decoded.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`DurableCheckpointError`] for read/metadata failures, oversized
374    /// files, invalid magic/version/encoding, checksum mismatch, or malformed
375    /// checkpoint content. A missing file returns `Ok(None)`.
376    pub fn load(&self) -> Result<Option<LoadedDurableCheckpoint>, DurableCheckpointError> {
377        let file = match File::open(&self.path) {
378            Ok(file) => file,
379            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
380            Err(source) => {
381                return Err(DurableCheckpointError::Read {
382                    path: self.path.clone(),
383                    source,
384                });
385            }
386        };
387        let reported_bytes = file
388            .metadata()
389            .map_err(|source| DurableCheckpointError::Read {
390                path: self.path.clone(),
391                source,
392            })?
393            .len();
394        if reported_bytes > self.max_checkpoint_bytes {
395            return Err(DurableCheckpointError::CheckpointTooLarge {
396                path: self.path.clone(),
397                bytes: reported_bytes,
398                max_bytes: self.max_checkpoint_bytes,
399            });
400        }
401        let mut data = Vec::new();
402        file.take(self.max_checkpoint_bytes.saturating_add(1))
403            .read_to_end(&mut data)
404            .map_err(|source| DurableCheckpointError::Read {
405                path: self.path.clone(),
406                source,
407            })?;
408        if data.len() as u64 > self.max_checkpoint_bytes {
409            return Err(DurableCheckpointError::CheckpointTooLarge {
410                path: self.path.clone(),
411                bytes: data.len() as u64,
412                max_bytes: self.max_checkpoint_bytes,
413            });
414        }
415        let Some(checksum_start) = data.len().checked_sub(CHECKPOINT_CHECKSUM_BYTES) else {
416            return Err(DurableCheckpointError::InvalidFormat {
417                path: self.path.clone(),
418            });
419        };
420        let encoded = &data[..checksum_start];
421        let expected = B256::from_slice(&data[checksum_start..]);
422        let actual = keccak256(encoded);
423        if actual != expected {
424            return Err(DurableCheckpointError::ChecksumMismatch {
425                path: self.path.clone(),
426            });
427        }
428        let snapshot = versioned::decode(
429            encoded,
430            CHECKPOINT_MAGIC,
431            CHECKPOINT_VERSION,
432            CHECKPOINT_LABEL,
433        )
434        .ok_or_else(|| DurableCheckpointError::InvalidFormat {
435            path: self.path.clone(),
436        })?;
437        Ok(Some(LoadedDurableCheckpoint { snapshot }))
438    }
439}
440
441fn checkpoint_coordinator(path: &Path) -> Arc<CheckpointWriteCoordinator> {
442    let key = normalized_checkpoint_path(path);
443    let coordinators = CHECKPOINT_COORDINATORS.get_or_init(|| Mutex::new(HashMap::new()));
444    let mut coordinators = coordinators
445        .lock()
446        .unwrap_or_else(std::sync::PoisonError::into_inner);
447    if let Some(coordinator) = coordinators.get(&key).and_then(Weak::upgrade) {
448        return coordinator;
449    }
450    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
451    let coordinator = Arc::new(CheckpointWriteCoordinator::default());
452    coordinators.insert(key, Arc::downgrade(&coordinator));
453    coordinator
454}
455
456fn normalized_checkpoint_path(path: &Path) -> PathBuf {
457    let absolute = if path.is_absolute() {
458        path.to_path_buf()
459    } else {
460        std::env::current_dir()
461            .map(|directory| directory.join(path))
462            .unwrap_or_else(|_| path.to_path_buf())
463    };
464
465    // Canonicalize the directory identity while deliberately preserving the
466    // destination entry itself. This makes aliases through symlinked parents
467    // share a writer coordinator, but an existing destination symlink is
468    // replaced by the atomic rename rather than followed to another file.
469    let Some(file_name) = absolute.file_name() else {
470        return normalize_existing_path_prefix(&absolute);
471    };
472    let parent = absolute.parent().unwrap_or_else(|| Path::new("."));
473    normalize_existing_path_prefix(parent).join(file_name)
474}
475
476fn normalize_existing_path_prefix(absolute: &Path) -> PathBuf {
477    // Resolve every existing prefix through the OS before normalizing a
478    // missing suffix. This preserves `symlink/..` semantics (which differ from
479    // blindly popping path components) while producing the same key before and
480    // after this store creates an ordinary missing directory suffix.
481    let components: Vec<_> = absolute.components().collect();
482    for split in (1..=components.len()).rev() {
483        let prefix: PathBuf = components[..split]
484            .iter()
485            .map(|component| component.as_os_str())
486            .collect();
487        let Ok(mut resolved) = prefix.canonicalize() else {
488            continue;
489        };
490        for component in &components[split..] {
491            match component {
492                Component::Prefix(prefix) => resolved.push(prefix.as_os_str()),
493                Component::RootDir => resolved.push(component.as_os_str()),
494                Component::CurDir => {}
495                Component::ParentDir => {
496                    let _ = resolved.pop();
497                }
498                Component::Normal(part) => resolved.push(part),
499            }
500        }
501        return resolved;
502    }
503
504    // Absolute roots normally make the loop succeed. Retain a deterministic
505    // fallback for unusual platforms/current-directory failures.
506    absolute.to_path_buf()
507}
508
509fn validate_capture_identity(
510    cache: &EvmCache,
511    metadata: &DurableCheckpointMetadata,
512) -> Result<(), DurableCheckpointError> {
513    if metadata.identity.chain_id != cache.chain_id {
514        return Err(DurableCheckpointError::CacheChainMismatch {
515            cache_chain_id: cache.chain_id,
516            checkpoint_chain_id: metadata.identity.chain_id,
517        });
518    }
519    Ok(())
520}
521
522fn persist_snapshot(
523    path: &Path,
524    snapshot: DurableCheckpointSnapshot,
525    coordinator: &CheckpointWriteCoordinator,
526    generation: u64,
527    max_checkpoint_bytes: u64,
528) -> Result<(), DurableCheckpointError> {
529    let _writer = coordinator
530        .writer
531        .lock()
532        .unwrap_or_else(std::sync::PoisonError::into_inner);
533    let latest = coordinator.latest_generation.load(Ordering::Acquire);
534    if generation != latest {
535        return Err(DurableCheckpointError::WriteSuperseded { generation, latest });
536    }
537    // Measure through bincode's size counter before allocating the encoded
538    // payload. The configured ceiling is a memory-safety boundary as well as a
539    // file-size boundary; checking only after `encode` would allocate the full
540    // checkpoint first.
541    let payload_bytes = bincode::serialized_size(&snapshot).map_err(|source| {
542        DurableCheckpointError::Encode(crate::errors::PersistenceError::serialize(
543            CHECKPOINT_LABEL,
544            source,
545        ))
546    })?;
547    let encoded_bytes = payload_bytes
548        .checked_add(CHECKPOINT_HEADER_BYTES)
549        .and_then(|bytes| bytes.checked_add(CHECKPOINT_CHECKSUM_BYTES as u64))
550        .ok_or(DurableCheckpointError::CheckpointSizeOverflow {
551            path: path.to_path_buf(),
552        })?;
553    if encoded_bytes > max_checkpoint_bytes {
554        return Err(DurableCheckpointError::CheckpointTooLarge {
555            path: path.to_path_buf(),
556            bytes: encoded_bytes,
557            max_bytes: max_checkpoint_bytes,
558        });
559    }
560    let mut data = versioned::encode(
561        CHECKPOINT_MAGIC,
562        CHECKPOINT_VERSION,
563        &snapshot,
564        CHECKPOINT_LABEL,
565    )
566    .map_err(DurableCheckpointError::Encode)?;
567    let checksum = keccak256(&data);
568    data.extend_from_slice(checksum.as_slice());
569    debug_assert_eq!(data.len() as u64, encoded_bytes);
570    atomic_replace(path, &data)
571}
572
573/// A decoded checkpoint awaiting identity/hash validation and restore.
574pub struct LoadedDurableCheckpoint {
575    snapshot: DurableCheckpointSnapshot,
576}
577
578impl LoadedDurableCheckpoint {
579    /// Inspect commit metadata before mutating the cache.
580    pub fn metadata(&self) -> &DurableCheckpointMetadata {
581        &self.snapshot.metadata
582    }
583
584    /// Restore the checkpoint into an already configured cache.
585    ///
586    /// The identity must match exactly and the cache must target the same chain.
587    /// Callers should validate `metadata().block.hash` against an authoritative
588    /// RPC source first when the checkpoint block is not finalized.
589    ///
590    /// # Errors
591    ///
592    /// Returns [`DurableCheckpointError::IdentityMismatch`] when `expected`
593    /// differs from stored metadata, or
594    /// [`DurableCheckpointError::CacheChainMismatch`] when the configured cache
595    /// targets another chain. The cache is not mutated on either error.
596    pub fn restore_into(
597        self,
598        cache: &mut EvmCache,
599        expected: &DurableCheckpointIdentity,
600    ) -> Result<DurableCheckpointMetadata, DurableCheckpointError> {
601        if &self.snapshot.metadata.identity != expected {
602            return Err(DurableCheckpointError::IdentityMismatch {
603                expected: expected.clone(),
604                actual: self.snapshot.metadata.identity.clone(),
605            });
606        }
607        if cache.chain_id != expected.chain_id {
608            return Err(DurableCheckpointError::CacheChainMismatch {
609                cache_chain_id: cache.chain_id,
610                checkpoint_chain_id: expected.chain_id,
611            });
612        }
613        Ok(self.snapshot.restore(cache))
614    }
615}
616
617#[derive(Serialize, Deserialize)]
618struct DurableCheckpointSnapshot {
619    metadata: DurableCheckpointMetadata,
620    state: EvmCacheStateSnapshot,
621}
622
623/// Complete mutable cache state used both by the on-disk checkpoint and by the
624/// checkpointed engine's in-process rollback guard.
625#[derive(Clone, Serialize, Deserialize)]
626pub(crate) struct EvmCacheStateSnapshot {
627    backend_accounts: Vec<(Address, AccountInfo)>,
628    backend_storage: Vec<(Address, Vec<(U256, U256)>)>,
629    backend_block_hashes: Vec<(U256, B256)>,
630    overlay: Cache,
631    token_decimals: HashMap<Address, u8>,
632    immutable_cache: ImmutableDataCache,
633    code_seeds: HashMap<Address, CodeSeedState>,
634    erc20_balance_slots: HashMap<Address, TrackedMapping>,
635    block: PersistedBlockId,
636    block_number: Option<u64>,
637    basefee: Option<u64>,
638    coinbase: Option<Address>,
639    prevrandao: Option<B256>,
640    block_gas_limit: Option<u64>,
641    timestamp_override: Option<u64>,
642    block_env_source: Option<BlockEnvSource>,
643    spec_id: SpecId,
644    snapshot_generation: u64,
645}
646
647#[derive(Clone, Copy, Serialize, Deserialize)]
648enum PersistedBlockId {
649    Hash {
650        hash: B256,
651        require_canonical: Option<bool>,
652    },
653    Latest,
654    Finalized,
655    Safe,
656    Earliest,
657    Pending,
658    Number(u64),
659}
660
661impl From<BlockId> for PersistedBlockId {
662    fn from(block: BlockId) -> Self {
663        match block {
664            BlockId::Hash(hash) => Self::Hash {
665                hash: hash.block_hash,
666                require_canonical: hash.require_canonical,
667            },
668            BlockId::Number(BlockNumberOrTag::Latest) => Self::Latest,
669            BlockId::Number(BlockNumberOrTag::Finalized) => Self::Finalized,
670            BlockId::Number(BlockNumberOrTag::Safe) => Self::Safe,
671            BlockId::Number(BlockNumberOrTag::Earliest) => Self::Earliest,
672            BlockId::Number(BlockNumberOrTag::Pending) => Self::Pending,
673            BlockId::Number(BlockNumberOrTag::Number(number)) => Self::Number(number),
674        }
675    }
676}
677
678impl From<PersistedBlockId> for BlockId {
679    fn from(block: PersistedBlockId) -> Self {
680        match block {
681            PersistedBlockId::Hash {
682                hash,
683                require_canonical,
684            } => BlockId::Hash(RpcBlockHash::from_hash(hash, require_canonical)),
685            PersistedBlockId::Latest => BlockId::latest(),
686            PersistedBlockId::Finalized => BlockId::finalized(),
687            PersistedBlockId::Safe => BlockId::safe(),
688            PersistedBlockId::Earliest => BlockId::earliest(),
689            PersistedBlockId::Pending => BlockId::pending(),
690            PersistedBlockId::Number(number) => BlockId::number(number),
691        }
692    }
693}
694
695impl DurableCheckpointSnapshot {
696    fn capture(cache: &EvmCache, metadata: DurableCheckpointMetadata) -> Self {
697        let mut state = EvmCacheStateSnapshot::capture(cache);
698        state.align_to_checkpoint_block(&metadata.block);
699        Self { metadata, state }
700    }
701
702    fn restore(self, cache: &mut EvmCache) -> DurableCheckpointMetadata {
703        let block_hash = self.metadata.block.hash;
704        self.state.restore(cache);
705
706        // Keep lazy RPC misses pinned to exactly the validated canonical block.
707        // A number-only pin could silently mix this snapshot with a replacement
708        // branch after a shallow reorg. Setting
709        // the backend pin directly avoids `set_block` clearing the restored EVM
710        // block context and incrementing the restored generation.
711        let block = alloy_eips::BlockId::from((block_hash, Some(true)));
712        cache.block = block;
713        let _ = cache.backend.set_pinned_block(block);
714
715        self.metadata
716    }
717}
718
719impl EvmCacheStateSnapshot {
720    pub(crate) fn capture(cache: &EvmCache) -> Self {
721        let (backend_accounts, backend_storage, backend_block_hashes) =
722            capture_backend_maps(&cache.blockchain_db);
723
724        Self {
725            backend_accounts,
726            backend_storage,
727            backend_block_hashes,
728            overlay: cache.db.cache.clone(),
729            token_decimals: cache.token_decimals.clone(),
730            immutable_cache: cache.immutable_cache.clone(),
731            code_seeds: cache.code_seeds.clone(),
732            erc20_balance_slots: cache.erc20_balance_slots.clone(),
733            block: cache.block.into(),
734            block_number: cache.block_number,
735            basefee: cache.basefee,
736            coinbase: cache.coinbase,
737            prevrandao: cache.prevrandao,
738            block_gas_limit: cache.block_gas_limit,
739            timestamp_override: cache.timestamp_override,
740            block_env_source: cache.block_env_source,
741            spec_id: cache.spec_id,
742            snapshot_generation: cache.snapshot_generation,
743        }
744    }
745
746    /// Make the persisted execution context internally agree with the
747    /// checkpoint's authoritative canonical coverage.
748    ///
749    /// Compact indexer progress can advance the checkpoint without delivering
750    /// a full header. In that case carrying an older cache `NUMBER`, timestamp,
751    /// or fee environment alongside the newer exact pin would create a split
752    /// state on restore. A full environment is retained only when cache
753    /// provenance proves it came from this exact number/hash and any supplied
754    /// checkpoint timestamp agrees. Otherwise number and timestamp are aligned
755    /// from metadata and unproven header-only fields are cleared.
756    fn align_to_checkpoint_block(&mut self, block: &DurableCheckpointBlock) {
757        let preserve_full_env = matches!(
758            self.block_env_source,
759            Some(BlockEnvSource::VerifiedHash { number, hash })
760                if number == block.number
761                    && hash == block.hash
762                    && block
763                        .timestamp
764                        .zip(self.timestamp_override)
765                        .is_none_or(|(expected, actual)| expected == actual)
766        );
767        self.block = PersistedBlockId::Hash {
768            hash: block.hash,
769            require_canonical: Some(true),
770        };
771        self.block_number = Some(block.number);
772        if !preserve_full_env {
773            self.timestamp_override = block.timestamp;
774            self.basefee = None;
775            self.coinbase = None;
776            self.prevrandao = None;
777            self.block_gas_limit = None;
778            self.block_env_source = None;
779        }
780    }
781
782    pub(crate) fn restore(self, cache: &mut EvmCache) {
783        {
784            let mut accounts = cache.blockchain_db.accounts().write();
785            accounts.clear();
786            accounts.extend(self.backend_accounts);
787        }
788        {
789            let mut storage = cache.blockchain_db.storage().write();
790            storage.clear();
791            storage.extend(
792                self.backend_storage
793                    .into_iter()
794                    .map(|(address, slots)| (address, slots.into_iter().collect())),
795            );
796        }
797        {
798            let mut hashes = cache.blockchain_db.block_hashes().write();
799            hashes.clear();
800            hashes.extend(self.backend_block_hashes);
801        }
802
803        cache.db.cache = self.overlay;
804        cache.token_decimals = self.token_decimals;
805        cache.immutable_cache = self.immutable_cache;
806        cache.code_seeds = self.code_seeds;
807        cache.erc20_balance_slots = self.erc20_balance_slots;
808        let block = BlockId::from(self.block);
809        cache.block = block;
810        let _ = cache.backend.set_pinned_block(block);
811        cache.block_number = self.block_number;
812        cache.basefee = self.basefee;
813        cache.coinbase = self.coinbase;
814        cache.prevrandao = self.prevrandao;
815        cache.block_gas_limit = self.block_gas_limit;
816        cache.timestamp_override = self.timestamp_override;
817        cache.block_env_source = self.block_env_source;
818        cache.spec_id = self.spec_id;
819        cache.snapshot_generation = self.snapshot_generation;
820        cache.base = None;
821        cache.base_dirty.clear();
822        cache.base_full_rebuild = true;
823        cache.base_storage_lens.clear();
824    }
825}
826
827type BackendMapsSnapshot = (
828    Vec<(Address, AccountInfo)>,
829    Vec<(Address, Vec<(U256, U256)>)>,
830    Vec<(U256, B256)>,
831);
832
833fn capture_backend_maps(blockchain_db: &BlockchainDb) -> BackendMapsSnapshot {
834    // Hold all three backend read guards together. `SharedBackend` applies
835    // queued account/storage/block-hash mutations through these same locks;
836    // retaining the earlier guards while later maps are cloned therefore gives
837    // the snapshot one coherent point-in-time prefix instead of an impossible
838    // old-account/new-storage mixture. The backend handler never holds more
839    // than one of these locks, so this stable order cannot form a cycle with
840    // normal lazy RPC population.
841    let accounts = blockchain_db.accounts().read();
842    let storage = blockchain_db.storage().read();
843    let block_hashes = blockchain_db.block_hashes().read();
844    let backend_accounts = accounts
845        .iter()
846        .map(|(address, info)| (*address, info.clone()))
847        .collect();
848    let backend_storage = storage
849        .iter()
850        .map(|(address, slots)| {
851            (
852                *address,
853                slots.iter().map(|(key, value)| (*key, *value)).collect(),
854            )
855        })
856        .collect();
857    let backend_block_hashes = block_hashes
858        .iter()
859        .map(|(number, hash)| (*number, *hash))
860        .collect();
861    (backend_accounts, backend_storage, backend_block_hashes)
862}
863
864#[cfg(test)]
865mod tests {
866    use std::{
867        fs,
868        sync::{Arc, Barrier},
869        thread,
870        time::{Duration, Instant},
871    };
872
873    use foundry_fork_db::{BlockchainDb, cache::BlockchainDbMeta};
874
875    use super::capture_backend_maps;
876    #[cfg(unix)]
877    use super::create_unique_temp_file;
878
879    #[test]
880    fn generation_reservation_never_wraps_at_exhaustion() {
881        let store = super::DurableCheckpointStore {
882            path: "unused-checkpoint-test".into(),
883            coordinator: Arc::new(super::CheckpointWriteCoordinator::default()),
884            max_checkpoint_bytes: 1,
885        };
886        assert_eq!(store.reserve_generation().unwrap(), 1);
887        store
888            .coordinator
889            .latest_generation
890            .store(u64::MAX - 1, std::sync::atomic::Ordering::Release);
891        assert_eq!(store.reserve_generation().unwrap(), u64::MAX);
892        assert!(matches!(
893            store.reserve_generation(),
894            Err(super::DurableCheckpointError::GenerationExhausted)
895        ));
896        assert_eq!(
897            store
898                .coordinator
899                .latest_generation
900                .load(std::sync::atomic::Ordering::Acquire),
901            u64::MAX
902        );
903    }
904
905    #[test]
906    fn backend_capture_retains_earlier_guards_while_waiting_for_later_maps() {
907        let blockchain_db = Arc::new(BlockchainDb::new(BlockchainDbMeta::default(), None));
908        let storage_guard = blockchain_db.storage().write();
909        let start = Arc::new(Barrier::new(2));
910        let worker_db = Arc::clone(&blockchain_db);
911        let worker_start = Arc::clone(&start);
912        let capture = thread::spawn(move || {
913            worker_start.wait();
914            capture_backend_maps(&worker_db)
915        });
916        start.wait();
917
918        // The worker can acquire the accounts read guard, but must block on the
919        // storage write guard held above. A per-map clone that dropped the first
920        // guard would allow this write; coherent capture must keep rejecting it.
921        let deadline = Instant::now() + Duration::from_secs(2);
922        let retained_accounts_guard = loop {
923            if blockchain_db.accounts().try_write().is_none() {
924                break true;
925            }
926            if Instant::now() >= deadline {
927                break false;
928            }
929            thread::yield_now();
930        };
931        assert!(
932            retained_accounts_guard,
933            "capture must retain the accounts guard while awaiting storage"
934        );
935
936        drop(storage_guard);
937        capture.join().expect("capture thread");
938    }
939
940    #[cfg(unix)]
941    #[test]
942    fn stale_temp_candidate_is_skipped_without_blocking_checkpoint_progress() {
943        use std::os::unix::fs::PermissionsExt;
944
945        let root = std::env::temp_dir().join(format!(
946            "evm-fork-cache-stale-temp-{}-{}",
947            std::process::id(),
948            super::NEXT_TEMP_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
949        ));
950        fs::create_dir_all(&root).expect("create test directory");
951        let destination = root.join("checkpoint.bin");
952        let stale = root.join(".checkpoint.bin.first");
953        let fresh = root.join(".checkpoint.bin.second");
954        fs::write(&stale, b"stale crash residue").expect("precreate first candidate");
955        let mut candidates = [stale.clone(), fresh.clone()].into_iter();
956
957        let (selected, file) = create_unique_temp_file(&destination, || {
958            candidates.next().expect("bounded test candidates")
959        })
960        .expect("collision must retry with the next candidate");
961        drop(file);
962
963        assert_eq!(selected, fresh);
964        assert_eq!(
965            fs::metadata(&selected)
966                .expect("fresh temp metadata")
967                .permissions()
968                .mode()
969                & 0o777,
970            0o600,
971            "checkpoint temp files contain provider cursors and must be owner-only"
972        );
973        assert_eq!(
974            fs::read(&stale).expect("stale file remains"),
975            b"stale crash residue"
976        );
977        fs::remove_dir_all(root).expect("remove test directory");
978    }
979}
980
981#[cfg(unix)]
982fn atomic_replace(path: &Path, data: &[u8]) -> Result<(), DurableCheckpointError> {
983    let parent = path.parent().unwrap_or_else(|| Path::new("."));
984    fs::create_dir_all(parent).map_err(|source| DurableCheckpointError::CreateDir {
985        path: parent.to_path_buf(),
986        source,
987    })?;
988
989    let (temp_path, mut file) = create_unique_temp_file(path, || next_temp_path(path))?;
990    let result = (|| {
991        file.write_all(data)
992            .and_then(|()| file.sync_all())
993            .map_err(|source| DurableCheckpointError::Write {
994                path: temp_path.clone(),
995                source,
996            })?;
997        fs::rename(&temp_path, path).map_err(|source| DurableCheckpointError::Rename {
998            from: temp_path.clone(),
999            to: path.to_path_buf(),
1000            source,
1001        })?;
1002        sync_parent_directory(parent)?;
1003        Ok(())
1004    })();
1005
1006    if result.is_err() {
1007        let _ = fs::remove_file(&temp_path);
1008    }
1009    result
1010}
1011
1012#[cfg(not(unix))]
1013fn atomic_replace(path: &Path, _data: &[u8]) -> Result<(), DurableCheckpointError> {
1014    Err(DurableCheckpointError::AtomicReplaceUnsupported {
1015        path: path.to_path_buf(),
1016    })
1017}
1018
1019#[cfg(unix)]
1020fn create_unique_temp_file(
1021    destination: &Path,
1022    mut next_candidate: impl FnMut() -> PathBuf,
1023) -> Result<(PathBuf, File), DurableCheckpointError> {
1024    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
1025
1026    for _ in 0..MAX_TEMP_CREATE_ATTEMPTS {
1027        let candidate = next_candidate();
1028        match OpenOptions::new()
1029            .write(true)
1030            .create_new(true)
1031            .mode(0o600)
1032            .open(&candidate)
1033        {
1034            Ok(file) => {
1035                // `mode` prevents a permissive creation window; explicitly
1036                // resetting permissions also makes the contract independent
1037                // of an unusually restrictive process umask.
1038                if let Err(source) = file.set_permissions(fs::Permissions::from_mode(0o600)) {
1039                    drop(file);
1040                    let _ = fs::remove_file(&candidate);
1041                    return Err(DurableCheckpointError::Write {
1042                        path: candidate,
1043                        source,
1044                    });
1045                }
1046                return Ok((candidate, file));
1047            }
1048            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => continue,
1049            Err(source) => {
1050                return Err(DurableCheckpointError::Write {
1051                    path: candidate,
1052                    source,
1053                });
1054            }
1055        }
1056    }
1057    Err(DurableCheckpointError::TemporaryPathExhausted {
1058        path: destination.to_path_buf(),
1059        attempts: MAX_TEMP_CREATE_ATTEMPTS,
1060    })
1061}
1062
1063#[cfg(unix)]
1064fn sync_parent_directory(parent: &Path) -> Result<(), DurableCheckpointError> {
1065    File::open(parent)
1066        .and_then(|directory| directory.sync_all())
1067        .map_err(|source| DurableCheckpointError::SyncDirectory {
1068            path: parent.to_path_buf(),
1069            source,
1070        })
1071}
1072
1073#[cfg(not(unix))]
1074fn sync_parent_directory(_parent: &Path) -> Result<(), DurableCheckpointError> {
1075    // Rust has no portable directory-fsync primitive. The file itself has
1076    // already been synced before the atomic replace on these targets.
1077    Ok(())
1078}
1079
1080#[cfg(unix)]
1081fn next_temp_path(path: &Path) -> PathBuf {
1082    let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
1083    let name = path
1084        .file_name()
1085        .and_then(|name| name.to_str())
1086        .unwrap_or("checkpoint");
1087    path.with_file_name(format!(".{name}.tmp-{}-{id}", std::process::id()))
1088}
1089
1090/// Durable checkpoint persistence or compatibility failure.
1091#[derive(Debug, thiserror::Error)]
1092#[non_exhaustive]
1093pub enum DurableCheckpointError {
1094    /// This platform cannot provide the crate's required atomic replacement.
1095    #[error("atomic durable checkpoint replacement for {path:?} is unsupported on this platform")]
1096    AtomicReplaceUnsupported {
1097        /// Destination checkpoint path.
1098        path: PathBuf,
1099    },
1100    /// The checkpoint payload could not be serialized.
1101    #[error(transparent)]
1102    Encode(#[from] crate::errors::PersistenceError),
1103    /// The blocking checkpoint writer task was cancelled or panicked.
1104    #[error("durable checkpoint writer task failed: {0}")]
1105    TaskJoin(#[source] tokio::task::JoinError),
1106    /// The in-process writer generation counter cannot advance safely.
1107    #[error("durable checkpoint writer generation exhausted")]
1108    GenerationExhausted,
1109    /// A newer write was requested before this writer could install its snapshot.
1110    #[error(
1111        "durable checkpoint write generation {generation} was superseded by generation {latest}"
1112    )]
1113    WriteSuperseded {
1114        /// Generation assigned to this write.
1115        generation: u64,
1116        /// Latest generation requested from this store.
1117        latest: u64,
1118    },
1119    /// The checkpoint file could not be read.
1120    #[error("failed to read durable checkpoint {path:?}: {source}")]
1121    Read {
1122        /// Checkpoint path.
1123        path: PathBuf,
1124        /// Filesystem failure.
1125        #[source]
1126        source: io::Error,
1127    },
1128    /// The file does not carry the supported magic, version, or payload.
1129    #[error("durable checkpoint {path:?} has an invalid or unsupported format")]
1130    InvalidFormat {
1131        /// Checkpoint path.
1132        path: PathBuf,
1133    },
1134    /// The checkpoint checksum does not match its encoded contents.
1135    #[error("durable checkpoint {path:?} failed its integrity checksum")]
1136    ChecksumMismatch {
1137        /// Checkpoint path.
1138        path: PathBuf,
1139    },
1140    /// The checkpoint exceeds this store's configured resource bound.
1141    #[error(
1142        "durable checkpoint {path:?} is {bytes} bytes, exceeding the configured {max_bytes}-byte limit"
1143    )]
1144    CheckpointTooLarge {
1145        /// Checkpoint path.
1146        path: PathBuf,
1147        /// Encoded file size observed or produced.
1148        bytes: u64,
1149        /// Maximum encoded size accepted by the store.
1150        max_bytes: u64,
1151    },
1152    /// Encoded checkpoint size overflowed the supported `u64` accounting.
1153    #[error("durable checkpoint {path:?} size exceeds supported accounting")]
1154    CheckpointSizeOverflow {
1155        /// Checkpoint path.
1156        path: PathBuf,
1157    },
1158    /// The checkpoint directory could not be created.
1159    #[error("failed to create durable checkpoint directory {path:?}: {source}")]
1160    CreateDir {
1161        /// Directory path.
1162        path: PathBuf,
1163        /// Filesystem failure.
1164        #[source]
1165        source: io::Error,
1166    },
1167    /// The temporary checkpoint file could not be written or synced.
1168    #[error("failed to write durable checkpoint {path:?}: {source}")]
1169    Write {
1170        /// Temporary checkpoint path.
1171        path: PathBuf,
1172        /// Filesystem failure.
1173        #[source]
1174        source: io::Error,
1175    },
1176    /// Every bounded unique temporary-file candidate already existed.
1177    #[error(
1178        "failed to allocate a unique temporary file for durable checkpoint {path:?} after {attempts} attempts"
1179    )]
1180    TemporaryPathExhausted {
1181        /// Destination checkpoint path.
1182        path: PathBuf,
1183        /// Number of collision retries attempted.
1184        attempts: usize,
1185    },
1186    /// The synced temporary file could not be atomically installed.
1187    #[error("failed to replace durable checkpoint {to:?} from {from:?}: {source}")]
1188    Rename {
1189        /// Temporary checkpoint path.
1190        from: PathBuf,
1191        /// Destination checkpoint path.
1192        to: PathBuf,
1193        /// Filesystem failure.
1194        #[source]
1195        source: io::Error,
1196    },
1197    /// The checkpoint rename committed but its containing directory could not
1198    /// be synced. The file may exist, but the caller must not acknowledge the
1199    /// delivery because crash durability was not established.
1200    #[error("failed to sync durable checkpoint directory {path:?}: {source}")]
1201    SyncDirectory {
1202        /// Directory path.
1203        path: PathBuf,
1204        /// Filesystem failure.
1205        #[source]
1206        source: io::Error,
1207    },
1208    /// A checkpoint was opened under a different subscriber or handler schema.
1209    #[error("durable checkpoint identity mismatch: expected {expected:?}, found {actual:?}")]
1210    IdentityMismatch {
1211        /// Requested consumer identity.
1212        expected: DurableCheckpointIdentity,
1213        /// Stored consumer identity.
1214        actual: DurableCheckpointIdentity,
1215    },
1216    /// The configured cache and checkpoint identity target different chains.
1217    #[error(
1218        "durable checkpoint chain {checkpoint_chain_id} does not match cache chain {cache_chain_id}"
1219    )]
1220    CacheChainMismatch {
1221        /// Current cache chain id.
1222        cache_chain_id: u64,
1223        /// Stored/requested checkpoint chain id.
1224        checkpoint_chain_id: u64,
1225    },
1226}