Skip to main content

mega_evme/common/provider/
cache_store.rs

1//! Clean-exit cache persistence and the on-disk envelope format.
2//!
3//! Two persistence shapes share [`RpcCacheStore`]:
4//!
5//! - **Provider cache** (`--rpc.cache-dir`): per-chain alloy `SharedCache` dump.
6//! - **Fixture capture** (`--rpc.capture-file`): transport-level JSON envelope (`{version,
7//!   chain_id, cache, external_env}`) produced by [`CacheFileEnvelope`].
8//!
9//! The envelope is v1. Forward-incompatible changes bump `ENVELOPE_VERSION`;
10//! additive fields use `#[serde(default)]` instead.
11
12use std::{
13    fmt, fs,
14    io::Write as _,
15    path::{Path, PathBuf},
16};
17
18use alloy_provider::layers::SharedCache;
19use serde::{Deserialize, Serialize};
20use tracing::{info, warn};
21
22use super::transport::TransportCache;
23use crate::common::{EvmeError, Result};
24
25/// Clean-exit cache persistence handle.
26///
27/// An `RpcCacheStore` may internally have nothing to persist — non-fork run,
28/// `--rpc.cache-size 0`, or `--rpc.no-cache-file`. In any of those cases
29/// `persist()` is a no-op. Callers do not and must not branch on whether
30/// a given store is real or no-op; the whole point of this type is a single
31/// uniform persistence entry point.
32///
33/// # Why not `Drop`
34///
35/// Persistence is **clean-exit-only**: callers invoke `persist()` explicitly on the
36/// success path. `Drop` also runs on panic and error unwind, so a `Drop`-based
37/// implementation would silently persist partial-run state. That is a correctness
38/// violation, not a style choice — do not "simplify" this type into a `Drop` impl.
39pub struct RpcCacheStore {
40    /// `Some` when there is a cache to persist on clean exit; `None` is the no-op state.
41    inner: Option<RpcCacheStoreInner>,
42}
43
44/// Discriminated inner state of [`RpcCacheStore`].
45///
46/// Variants are named for their role in the workflow, not their on-disk shape:
47/// `ProviderCache` is the provider-level LRU backing `--rpc.cache-dir`;
48/// `FixtureCapture` is the transport-level envelope backing `--rpc.capture-file`.
49enum RpcCacheStoreInner {
50    /// Provider-level LRU cache persisted to a per-chain file (`--rpc.cache-dir`).
51    ProviderCache { cache: SharedCache, path: PathBuf },
52    /// Transport-level fixture envelope captured for offline replay (`--rpc.capture-file`).
53    FixtureCapture {
54        cache: TransportCache,
55        path: PathBuf,
56        chain_id: u64,
57        /// Optional external-env snapshot to write into the envelope at
58        /// persist time. Populated via [`RpcCacheStore::set_external_env`]
59        /// by the command layer once it has computed the effective value
60        /// from CLI + prior envelope.
61        external_env: Option<ExternalEnvSnapshot>,
62    },
63}
64
65impl RpcCacheStore {
66    /// Construct a store backed by a provider-level LRU cache file.
67    ///
68    /// `pub(super)` because only the builders in `mod.rs` construct these;
69    /// external callers go through `build_provider` / `build_capture_provider`.
70    pub(super) fn new(cache: SharedCache, cache_path: PathBuf) -> Self {
71        Self { inner: Some(RpcCacheStoreInner::ProviderCache { cache, path: cache_path }) }
72    }
73
74    /// Construct a store backed by a transport-level fixture envelope file.
75    ///
76    /// `pub(super)` to keep the `TransportCache` parameter from leaking out of
77    /// this module. The snapshot field starts empty; callers inject it later
78    /// via [`Self::set_external_env`].
79    pub(super) fn new_envelope(cache: TransportCache, path: PathBuf, chain_id: u64) -> Self {
80        Self {
81            inner: Some(RpcCacheStoreInner::FixtureCapture {
82                cache,
83                path,
84                chain_id,
85                external_env: None,
86            }),
87        }
88    }
89
90    /// Attach an external-env snapshot to a fixture-capture store.
91    ///
92    /// Silent no-op for [`RpcCacheStoreInner::ProviderCache`] and no-op
93    /// variants — callers use the same uniform interface regardless of which
94    /// variant the builder produced, matching the `persist()` contract.
95    pub fn set_external_env(&mut self, ext: ExternalEnvSnapshot) {
96        if let Some(RpcCacheStoreInner::FixtureCapture { external_env, .. }) = &mut self.inner {
97            *external_env = Some(ext);
98        }
99    }
100
101    /// Construct a no-op store.
102    ///
103    /// `pub(crate)` because `common/state.rs` calls it for the non-fork path.
104    pub(crate) fn noop() -> Self {
105        Self { inner: None }
106    }
107
108    // The three accessors below are gated on `cfg(any(test, feature =
109    // "test-utils"))` because they leak internal state that the owner type
110    // is otherwise designed to hide. Production code must not branch on any
111    // of them — call `persist()` instead, which is a no-op when there is
112    // nothing to persist. Tests need them to assert wiring and to seed
113    // cache entries without going through a real (or mock) RPC round-trip.
114
115    /// True if this store is the no-op variant (nothing to persist).
116    #[cfg(any(test, feature = "test-utils"))]
117    pub fn is_noop(&self) -> bool {
118        self.inner.is_none()
119    }
120
121    /// Returns the underlying [`SharedCache`] (provider-cache path only), or `None`.
122    #[cfg(any(test, feature = "test-utils"))]
123    pub fn cache(&self) -> Option<&SharedCache> {
124        match &self.inner {
125            Some(RpcCacheStoreInner::ProviderCache { cache, .. }) => Some(cache),
126            _ => None,
127        }
128    }
129
130    /// Returns the resolved cache file path, or `None` for a no-op store.
131    #[cfg(any(test, feature = "test-utils"))]
132    pub fn cache_path(&self) -> Option<&Path> {
133        match &self.inner {
134            Some(
135                RpcCacheStoreInner::ProviderCache { path, .. } |
136                RpcCacheStoreInner::FixtureCapture { path, .. },
137            ) => Some(path.as_path()),
138            None => None,
139        }
140    }
141
142    /// Persist the cache to disk atomically. **Consumes the store** to enforce
143    /// "persist once, then stop" — see `Why not Drop` on this type.
144    ///
145    /// For fixture-capture stores, any `external_env` snapshot previously
146    /// attached via [`Self::set_external_env`] is written into the envelope.
147    ///
148    /// - **`ProviderCache`**: best-effort — failures are warn-logged and swallowed.
149    /// - **`FixtureCapture`**: hard error — the fixture is the primary output of capture mode.
150    /// - **No-op**: returns `Ok(())`.
151    pub fn persist(self) -> Result<()> {
152        let Some(inner) = self.inner else { return Ok(()) };
153        match inner {
154            RpcCacheStoreInner::ProviderCache { cache, path } => {
155                match save_cache_atomic(&cache, &path) {
156                    Ok(()) => info!(path = %path.display(), "Persisted RPC cache"),
157                    Err(err) => warn!(
158                        path = %path.display(),
159                        error = %err,
160                        "Failed to save RPC cache (continuing)",
161                    ),
162                }
163                Ok(())
164            }
165            RpcCacheStoreInner::FixtureCapture { cache, path, chain_id, external_env } => {
166                let entry_count = cache.len();
167                CacheFileEnvelope::new(&cache, chain_id, external_env.as_ref()).save(&path)?;
168                info!(
169                    path = %path.display(),
170                    entries = entry_count,
171                    "Persisted RPC cache envelope",
172                );
173                Ok(())
174            }
175        }
176    }
177}
178
179// Manual `Debug` because `SharedCache` does not implement `Debug`.
180impl fmt::Debug for RpcCacheStore {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        match &self.inner {
183            Some(
184                RpcCacheStoreInner::ProviderCache { path, .. } |
185                RpcCacheStoreInner::FixtureCapture { path, .. },
186            ) => f.debug_struct("RpcCacheStore").field("path", path).finish_non_exhaustive(),
187            None => f.debug_struct("RpcCacheStore").field("inner", &Option::<()>::None).finish(),
188        }
189    }
190}
191
192/// Atomically persist `cache` to `target` via a temp file + rename.
193///
194/// All error paths include `target` in the returned [`std::io::Error`] so the
195/// warn-log in [`RpcCacheStore::persist`] identifies which file failed.
196fn save_cache_atomic(cache: &SharedCache, target: &Path) -> std::io::Result<()> {
197    let dir = target.parent().unwrap_or_else(|| Path::new("."));
198    let tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| {
199        std::io::Error::other(format!("failed to create temp file in {}: {e}", dir.display()))
200    })?;
201    let tmp_path = tmp.path().to_path_buf();
202
203    // alloy's save_cache takes a PathBuf, not a Write.
204    cache.save_cache(tmp_path).map_err(|e| {
205        std::io::Error::other(format!("failed to save cache for {}: {e}", target.display()))
206    })?;
207
208    // Atomic rename. persist() consumes the NamedTempFile without deleting it.
209    tmp.persist(target).map_err(|e| {
210        std::io::Error::other(format!(
211            "failed to rename temp file into {}: {}",
212            target.display(),
213            e.error,
214        ))
215    })?;
216    Ok(())
217}
218
219/// Envelope version accepted by this build.
220const ENVELOPE_VERSION: u32 = 1;
221
222/// On-disk envelope format shared by `--rpc.capture-file` (write) and
223/// `--rpc.replay-file` (read). Contains a transport-level cache dump,
224/// chain ID, and optional external environment snapshot.
225#[derive(Debug, Serialize, Deserialize)]
226pub(super) struct CacheFileEnvelope {
227    /// Schema version (currently always 1, reserved for future format changes).
228    version: u32,
229    /// Chain ID at the time of capture.
230    pub(super) chain_id: u64,
231    /// Transport-level cache entries: `[{key, value}, ...]`.
232    pub(super) cache: serde_json::Value,
233    /// External environment inputs not derivable from RPC (e.g., SALT bucket capacities).
234    #[serde(default)]
235    pub(super) external_env: Option<ExternalEnvSnapshot>,
236}
237
238impl CacheFileEnvelope {
239    /// Build an envelope for the given transport cache and metadata.
240    pub(super) fn new(
241        cache: &TransportCache,
242        chain_id: u64,
243        external_env: Option<&ExternalEnvSnapshot>,
244    ) -> Self {
245        Self {
246            version: ENVELOPE_VERSION,
247            chain_id,
248            cache: cache.to_value(),
249            external_env: external_env.cloned(),
250        }
251    }
252
253    /// Read and validate an envelope from `path`.
254    pub(super) fn load(path: &Path) -> Result<Self> {
255        let content = fs::read_to_string(path).map_err(|e| {
256            EvmeError::FixtureError(format!(
257                "Failed to read RPC cache file {}: {e}",
258                path.display()
259            ))
260        })?;
261        let envelope: Self = serde_json::from_str(&content).map_err(|e| {
262            EvmeError::FixtureError(format!(
263                "Failed to parse RPC cache file {}: {e}",
264                path.display()
265            ))
266        })?;
267        if envelope.version != ENVELOPE_VERSION {
268            return Err(EvmeError::FixtureError(format!(
269                "Unsupported cache file version {} in '{}'; expected {ENVELOPE_VERSION}",
270                envelope.version,
271                path.display(),
272            )));
273        }
274        Ok(envelope)
275    }
276
277    /// Atomically write this envelope to `path`.
278    pub(super) fn save(&self, path: &Path) -> Result<()> {
279        let dir = path.parent().unwrap_or_else(|| Path::new("."));
280        fs::create_dir_all(dir).map_err(|e| {
281            EvmeError::FixtureError(format!(
282                "Failed to create cache file directory {}: {e}",
283                dir.display()
284            ))
285        })?;
286
287        let serialized = serde_json::to_string_pretty(self).map_err(|e| {
288            EvmeError::FixtureError(format!(
289                "Failed to serialize envelope for {}: {e}",
290                path.display()
291            ))
292        })?;
293
294        let mut tmp = tempfile::NamedTempFile::new_in(dir).map_err(|e| {
295            EvmeError::FixtureError(format!("Failed to create temp file in {}: {e}", dir.display()))
296        })?;
297        tmp.write_all(serialized.as_bytes())
298            .map_err(|e| EvmeError::FixtureError(format!("Failed to write envelope: {e}")))?;
299        tmp.persist(path).map_err(|e| {
300            EvmeError::FixtureError(format!(
301                "Failed to persist envelope to {}: {e}",
302                path.display()
303            ))
304        })?;
305
306        Ok(())
307    }
308}
309
310/// Snapshot of mega-evm external environment inputs not derivable from RPC.
311#[derive(Debug, Clone, Default, Serialize, Deserialize)]
312pub struct ExternalEnvSnapshot {
313    /// SALT bucket capacity pairs `(bucket_id, capacity)`.
314    #[serde(default)]
315    pub bucket_capacities: Vec<(u32, u64)>,
316}
317
318#[cfg(test)]
319mod tests {
320    use alloy_primitives::keccak256;
321
322    use super::*;
323
324    /// Save a cache as an envelope, load it back, and verify the round-trip
325    /// preserves version, `chain_id`, cache payload, and `external_env`.
326    #[test]
327    fn test_envelope_roundtrip_preserves_cache() {
328        let dir = tempfile::tempdir().expect("tempdir");
329        let path = dir.path().join("test-cache.json");
330
331        let cache = TransportCache::new();
332        cache
333            .merge(&serde_json::json!([
334                {
335                    "key": keccak256("eth_blockNumber"),
336                    "value": r#"{"id":0,"jsonrpc":"2.0","result":"0x1"}"#,
337                }
338            ]))
339            .expect("seed cache");
340
341        let ext = ExternalEnvSnapshot { bucket_capacities: vec![(1, 100), (2, 200)] };
342        CacheFileEnvelope::new(&cache, 4326, Some(&ext)).save(&path).expect("save envelope");
343
344        let envelope = CacheFileEnvelope::load(&path).expect("load envelope");
345        assert_eq!(envelope.version, 1);
346        assert_eq!(envelope.chain_id, 4326);
347        assert!(envelope.cache.is_array(), "cache should be a JSON array");
348
349        // Verify the cache entry survived the round-trip.
350        let loaded = TransportCache::from_value(&envelope.cache).expect("from_value");
351        assert_eq!(loaded.len(), 1);
352
353        let env = envelope.external_env.expect("external_env should round-trip");
354        assert_eq!(env.bucket_capacities, vec![(1, 100), (2, 200)]);
355    }
356
357    /// An envelope with an unrecognized version must be rejected so that a
358    /// future format change doesn't silently produce wrong results.
359    #[test]
360    fn test_envelope_rejects_unknown_version() {
361        let dir = tempfile::tempdir().expect("tempdir");
362        let p = dir.path().join("v2.json");
363        fs::write(&p, r#"{"version":2,"chain_id":1,"cache":[]}"#).unwrap();
364        let err = CacheFileEnvelope::load(&p).expect_err("version 2 should be rejected");
365        let msg = format!("{err}");
366        assert!(msg.contains("Unsupported"), "error should mention Unsupported: {msg}");
367    }
368
369    /// `CacheFileEnvelope::load` must reject envelopes missing required fields.
370    #[test]
371    fn test_envelope_rejects_missing_fields() {
372        let dir = tempfile::tempdir().expect("tempdir");
373
374        // Missing chain_id.
375        let p1 = dir.path().join("no-chain.json");
376        fs::write(&p1, r#"{"version":1,"cache":{}}"#).unwrap();
377        let err = CacheFileEnvelope::load(&p1).expect_err("missing chain_id");
378        let msg = format!("{err}");
379        assert!(msg.contains("parse"), "error should mention parse: {msg}");
380
381        // Missing cache.
382        let p2 = dir.path().join("no-cache.json");
383        fs::write(&p2, r#"{"version":1,"chain_id":1}"#).unwrap();
384        let err = CacheFileEnvelope::load(&p2).expect_err("missing cache");
385        let msg = format!("{err}");
386        assert!(msg.contains("parse"), "error should mention parse: {msg}");
387
388        // Missing version.
389        let p3 = dir.path().join("no-version.json");
390        fs::write(&p3, r#"{"chain_id":1,"cache":{}}"#).unwrap();
391        let err = CacheFileEnvelope::load(&p3).expect_err("missing version");
392        let msg = format!("{err}");
393        assert!(msg.contains("parse"), "error should mention parse: {msg}");
394    }
395}