Skip to main content

mega_evme/common/provider/
mod.rs

1//! RPC provider factory and on-disk cache store for mega-evme.
2//!
3//! Three provider builders for different use cases:
4//!
5//! - [`RpcArgs::build_provider`] — standard RPC with optional `--rpc.cache-dir` persistence.
6//! - [`RpcArgs::build_replay_provider`] — offline replay from a `--rpc.replay-file` envelope.
7//! - [`RpcArgs::build_capture_provider`] — RPC with transport-level capture to
8//!   `--rpc.capture-file`.
9//!
10//! The `--rpc.cache-dir` path uses alloy's provider-level `CacheLayer` (caches ~8 methods).
11//! The `--rpc.capture-file` / `--rpc.replay-file` paths use a transport-level
12//! [`CachingTransport`] / [`ReplayTransport`] that captures single JSON-RPC request/response
13//! pairs.
14
15mod cache_store;
16mod transport;
17
18use std::{
19    fs,
20    path::{Path, PathBuf},
21};
22
23use alloy_provider::{
24    layers::CacheLayer,
25    transport::{
26        layers::{RateLimitRetryPolicy, RetryBackoffLayer},
27        RpcError, TransportError, TransportErrorKind,
28    },
29    DynProvider, Provider, ProviderBuilder,
30};
31use alloy_rpc_client::{ClientBuilder, RpcClient};
32use clap::Parser;
33use tracing::{debug, info, warn};
34
35pub use self::cache_store::{ExternalEnvSnapshot, RpcCacheStore};
36use self::{
37    cache_store::CacheFileEnvelope,
38    transport::{CachingTransport, ReplayTransport, TransportCache},
39};
40use super::{EvmeError, Result};
41
42/// OP-stack provider type used throughout mega-evme.
43pub type OpProvider = DynProvider<op_alloy_network::Optimism>;
44
45/// Return value of the `RpcArgs::build_*_provider` methods.
46#[derive(Debug)]
47pub struct BuildProviderOutput {
48    /// Configured OP-stack provider. Already wrapped with the retry layer and (unless
49    /// the cache is disabled) the in-memory cache layer.
50    pub provider: OpProvider,
51    /// Clean-exit cache persistence handle. Call [`RpcCacheStore::persist`] on the
52    /// success path; no-op when the cache is disabled.
53    pub cache_store: RpcCacheStore,
54    /// Chain id resolved during provider construction. Always populated —
55    /// comes from `eth_chainId` (standard/capture) or the envelope (replay).
56    pub chain_id: u64,
57    /// External environment snapshot from the envelope (replay and capture refresh).
58    pub external_env: Option<ExternalEnvSnapshot>,
59}
60
61/// Configuration for building an RPC provider.
62#[derive(Parser, Debug, Clone)]
63#[command(next_help_heading = "RPC Options")]
64pub struct RpcArgs {
65    /// RPC URL. Required for networked operation (replay, run --fork, tx --fork).
66    #[arg(
67        long = "rpc",
68        visible_aliases = ["rpc-url"],
69        alias = "fork.rpc",
70    )]
71    pub rpc_url: Option<String>,
72
73    /// Capture JSON-RPC responses to a single file for later offline replay.
74    /// Requires `--rpc`.
75    /// If the file already exists, its entries are loaded and merged;
76    /// missing entries are fetched via the RPC endpoint and persisted on clean exit.
77    /// Cannot be used with --rpc.replay-file, --rpc.cache-dir, --rpc.clear-cache,
78    /// --rpc.no-cache-file, or --rpc.cache-size.
79    #[arg(
80        long = "rpc.capture-file",
81        value_parser = parse_non_empty_path,
82        requires = "rpc_url",
83        conflicts_with_all = ["replay_file", "cache_dir", "clear_cache", "no_cache_file", "cache_size"],
84    )]
85    pub capture_file: Option<PathBuf>,
86
87    /// Replay from a previously captured JSON-RPC fixture file (offline).
88    /// Cannot be used with `--rpc`.
89    /// Any RPC miss is a hard error; the file is never written.
90    /// Cannot be used with --rpc.capture-file, --rpc.cache-dir, --rpc.clear-cache,
91    /// --rpc.no-cache-file, or --rpc.cache-size.
92    #[arg(
93        long = "rpc.replay-file",
94        value_parser = parse_non_empty_path,
95        conflicts_with_all = ["rpc_url", "capture_file", "cache_dir", "clear_cache", "no_cache_file", "cache_size"],
96    )]
97    pub replay_file: Option<PathBuf>,
98
99    /// Maximum number of items to keep in the in-memory RPC LRU cache.
100    /// Set to 0 to disable the cache layer entirely.
101    #[arg(id = "cache_size", long = "rpc.cache-size", default_value_t = 10_000)]
102    pub cache_size: u32,
103
104    /// Directory for per-chain RPC cache files.
105    ///
106    /// Each chain's cache is stored as `{cache_dir}/rpc-cache-{chain_id}.json`. Different chains
107    /// cannot share a file, so cross-chain contamination is impossible by construction.
108    ///
109    /// Defaults to the platform cache directory (`$XDG_CACHE_HOME/mega-evme/rpc` on
110    /// Linux, `~/Library/Caches/mega-evme/rpc` on macOS). Pass `--rpc.no-cache-file`
111    /// to disable on-disk persistence entirely.
112    #[arg(long = "rpc.cache-dir", value_parser = parse_non_empty_path)]
113    pub cache_dir: Option<PathBuf>,
114
115    /// Disable on-disk cache persistence. The in-memory LRU cache still applies — use
116    /// `--rpc.cache-size 0` to disable that too.
117    #[arg(long = "rpc.no-cache-file")]
118    pub no_cache_file: bool,
119
120    /// Delete the current chain's cache file before loading it. Recovery path for a
121    /// polluted or corrupt cache file. If the unlink itself fails (e.g. insufficient
122    /// permissions), `mega-evme` aborts rather than silently reloading the stale file.
123    #[arg(long = "rpc.clear-cache")]
124    pub clear_cache: bool,
125
126    /// Maximum number of times the transport layer will retry a failing RPC request.
127    /// Retries trigger on HTTP 429 / 503, JSON-RPC rate-limit error responses, and
128    /// transport failures surfaced as `TransportErrorKind::Custom` (connection refused,
129    /// DNS failure, TLS handshake, etc.). Set to 0 to disable retries entirely.
130    #[arg(long = "rpc.max-retries", default_value_t = 5)]
131    pub max_retries: u32,
132
133    /// Fixed sleep duration, in milliseconds, inserted between retry attempts unless the
134    /// server supplies its own backoff hint. This layer does not perform exponential backoff.
135    #[arg(long = "rpc.backoff-ms", default_value_t = 1_000)]
136    pub backoff_ms: u64,
137
138    /// Compute units per second budget passed to the retry layer's rate-limit accounting.
139    #[arg(long = "rpc.rate-limit", default_value_t = 660)]
140    pub compute_units_per_sec: u64,
141}
142
143impl RpcArgs {
144    /// Build an RPC provider using `--rpc <URL>` with the standard `--rpc.cache-dir` path.
145    ///
146    /// Used by `replay` (online), `run --fork`, and `tx --fork`. Requires `rpc_url` to
147    /// be `Some` — callers validate this before calling. For offline replay and capture
148    /// mode, use [`Self::build_replay_provider`] and [`Self::build_capture_provider`].
149    pub async fn build_provider(&self) -> Result<BuildProviderOutput> {
150        let rpc_url_str = self.rpc_url.as_deref().ok_or_else(|| {
151            EvmeError::InvalidInput("No RPC URL provided. Pass '--rpc <URL>'.".to_string())
152        })?;
153
154        let url: reqwest::Url = rpc_url_str.parse().map_err(|e| {
155            EvmeError::RpcError(format!("Invalid RPC URL '{}': {}", rpc_url_str, e))
156        })?;
157
158        // 1. Resolve chain id (always needed by downstream consumers).
159        let chain_id = self.resolve_chain_id(url.clone()).await?;
160
161        // 2. Fast path: cache fully disabled.
162        if self.cache_size == 0 {
163            let provider = build_bare_op_provider(self.build_retry_client(url));
164            info!(
165                rpc_url = %rpc_url_str,
166                max_retries = self.max_retries,
167                backoff_ms = self.backoff_ms,
168                "Built RPC provider (cache disabled)",
169            );
170            return Ok(BuildProviderOutput {
171                provider,
172                cache_store: RpcCacheStore::noop(),
173                chain_id,
174                external_env: None,
175            });
176        }
177
178        // 3. Resolve on-disk cache path (None when disk persistence is disabled).
179        let cache_path = if self.no_cache_file {
180            None
181        } else {
182            Some(resolve_cache_path(self.cache_dir.as_deref(), chain_id)?)
183        };
184
185        // 4. Build the cache layer and (optionally) the disk store.
186        let cache_layer = CacheLayer::new(self.cache_size);
187        let cache = cache_layer.cache();
188        let cache_store = match cache_path {
189            Some(path) => {
190                if self.clear_cache {
191                    if let Err(e) = fs::remove_file(&path) {
192                        if e.kind() != std::io::ErrorKind::NotFound {
193                            return Err(EvmeError::RpcError(format!(
194                                "Failed to clear RPC cache at {}: {e}",
195                                path.display(),
196                            )));
197                        }
198                    } else {
199                        info!(path = %path.display(), "Cleared existing RPC cache");
200                    }
201                }
202                if let Some(parent) = path.parent() {
203                    if let Err(e) = fs::create_dir_all(parent) {
204                        warn!(
205                            path = %parent.display(),
206                            error = %e,
207                            "Failed to create cache directory; persist may fail",
208                        );
209                    }
210                }
211                if path.exists() {
212                    if let Err(err) = cache.load_cache(path.clone()) {
213                        warn!(
214                            path = %path.display(),
215                            error = %err,
216                            "Failed to load RPC cache; starting empty",
217                        );
218                    }
219                }
220                RpcCacheStore::new(cache, path)
221            }
222            None => RpcCacheStore::noop(),
223        };
224
225        // 5. Build the cached provider.
226        let client = self.build_retry_client(url);
227        let provider = ProviderBuilder::new()
228            .disable_recommended_fillers()
229            .layer(cache_layer)
230            .network::<op_alloy_network::Optimism>()
231            .connect_client(client);
232
233        info!(
234            rpc_url = %rpc_url_str,
235            cache_size = self.cache_size,
236            max_retries = self.max_retries,
237            backoff_ms = self.backoff_ms,
238            "Built RPC provider",
239        );
240
241        Ok(BuildProviderOutput {
242            provider: DynProvider::new(provider),
243            cache_store,
244            chain_id,
245            external_env: None,
246        })
247    }
248
249    /// Build the provider in replay mode (`--rpc.replay-file` without `--rpc`).
250    ///
251    /// Loads the envelope's transport-level cache and builds the provider over
252    /// [`ReplayTransport`], which serves cached responses directly and returns
253    /// a descriptive error on cache miss. No `CacheLayer` is used — all caching
254    /// is at the transport level so every RPC method is covered.
255    pub async fn build_replay_provider(&self) -> Result<BuildProviderOutput> {
256        let path = self.replay_file.as_ref().expect("replay mode requires --rpc.replay-file");
257
258        let envelope = CacheFileEnvelope::load(path)?;
259        let chain_id = envelope.chain_id;
260        debug!(
261            path = %path.display(),
262            chain_id,
263            has_external_env = envelope.external_env.is_some(),
264            "Loaded replay envelope",
265        );
266
267        // Load the transport-level cache from the envelope.
268        let transport_cache = TransportCache::from_value(&envelope.cache)?;
269        debug!(entries = transport_cache.len(), "Seeded transport cache from envelope");
270
271        // Build provider over ReplayTransport — serves from cache, BackendGone on miss.
272        let replay_client = ClientBuilder::default()
273            .transport(ReplayTransport::new(path.clone(), transport_cache), true);
274        let provider = ProviderBuilder::new()
275            .disable_recommended_fillers()
276            .network::<op_alloy_network::Optimism>()
277            .connect_client(replay_client);
278
279        info!(
280            path = %path.display(),
281            chain_id,
282            "Built RPC provider (replay from cache file)",
283        );
284
285        Ok(BuildProviderOutput {
286            provider: DynProvider::new(provider),
287            cache_store: RpcCacheStore::noop(),
288            chain_id,
289            external_env: envelope.external_env,
290        })
291    }
292
293    /// Build the provider in capture mode (`--rpc.capture-file` + `--rpc`).
294    ///
295    /// If the capture file already exists, its entries are loaded into the transport-
296    /// level cache. Missing entries are fetched via the HTTP transport (with retry),
297    /// cached in-memory, and persisted as an envelope on clean exit. No `CacheLayer`
298    /// is used — all caching is at the transport level so every RPC method is covered.
299    pub async fn build_capture_provider(&self) -> Result<BuildProviderOutput> {
300        let path = self.capture_file.as_ref().expect("capture mode requires --rpc.capture-file");
301        let rpc_url_str = self.rpc_url.as_ref().expect("capture mode requires --rpc");
302
303        let url: reqwest::Url = rpc_url_str.parse().map_err(|e| {
304            EvmeError::RpcError(format!("Invalid RPC URL '{}': {}", rpc_url_str, e))
305        })?;
306
307        // Load existing envelope if the file exists.
308        let existing_envelope = if path.exists() {
309            let env = CacheFileEnvelope::load(path)?;
310            debug!(
311                path = %path.display(),
312                chain_id = env.chain_id,
313                has_external_env = env.external_env.is_some(),
314                "Found existing capture envelope; entries will merge after chain-id validation",
315            );
316            Some(env)
317        } else {
318            debug!(path = %path.display(), "No existing capture envelope; starting fresh");
319            None
320        };
321
322        // Start with an empty transport cache so the eth_chainId call below
323        // always hits the real endpoint — if we seeded from the existing
324        // envelope first, a stale eth_chainId entry would short-circuit the
325        // cross-chain validation.
326        let transport_cache = TransportCache::new();
327        let http = alloy_transport_http::Http::new(url.clone());
328        let caching = CachingTransport::new(http, transport_cache.clone());
329        let client = self.build_client(caching, &url);
330        let provider = ProviderBuilder::new()
331            .disable_recommended_fillers()
332            .network::<op_alloy_network::Optimism>()
333            .connect_client(client);
334
335        // Resolve chain_id through the caching provider (hits network, response captured).
336        let chain_id = provider.get_chain_id().await.map_err(|e| {
337            EvmeError::RpcError(format!("Failed to fetch chain ID from '{}': {e}", rpc_url_str))
338        })?;
339
340        // Validate chain_id match, then merge existing entries into the cache.
341        if let Some(ref env) = existing_envelope {
342            if env.chain_id != chain_id {
343                return Err(EvmeError::RpcError(format!(
344                    "Chain ID mismatch: cache file {} contains chain {} but endpoint returned {}",
345                    path.display(),
346                    env.chain_id,
347                    chain_id,
348                )));
349            }
350            transport_cache.merge(&env.cache)?;
351            debug!(
352                entries = transport_cache.len(),
353                "Merged existing envelope entries into capture cache",
354            );
355        }
356
357        info!(
358            rpc_url = %rpc_url_str,
359            path = %path.display(),
360            chain_id,
361            "Built RPC provider (capture to cache file)",
362        );
363
364        let prev_external_env = existing_envelope.and_then(|e| e.external_env);
365
366        Ok(BuildProviderOutput {
367            provider: DynProvider::new(provider),
368            cache_store: RpcCacheStore::new_envelope(transport_cache, path.clone(), chain_id),
369            chain_id,
370            external_env: prev_external_env,
371        })
372    }
373
374    /// Build an `RpcClient` over HTTP, wired with the configured retry layer.
375    fn build_retry_client(&self, url: reqwest::Url) -> RpcClient {
376        self.build_client(alloy_transport_http::Http::new(url.clone()), &url)
377    }
378
379    /// Build an `RpcClient` over an arbitrary transport, wired with the configured
380    /// retry layer (or bare when `max_retries == 0`). `url` is used only to detect
381    /// whether the endpoint is local.
382    fn build_client<T: alloy_transport::IntoBoxTransport>(
383        &self,
384        transport: T,
385        url: &reqwest::Url,
386    ) -> RpcClient {
387        let is_local =
388            url.host_str().is_some_and(|h| h == "localhost" || h == "127.0.0.1" || h == "::1");
389        if self.max_retries > 0 {
390            let policy = RateLimitRetryPolicy::default().or(|err: &TransportError| {
391                matches!(err, RpcError::Transport(TransportErrorKind::Custom(_)))
392            });
393            let retry = RetryBackoffLayer::new_with_policy(
394                self.max_retries,
395                self.backoff_ms,
396                self.compute_units_per_sec,
397                policy,
398            );
399            ClientBuilder::default().layer(retry).transport(transport, is_local)
400        } else {
401            ClientBuilder::default().transport(transport, is_local)
402        }
403    }
404
405    /// Resolve the chain ID by issuing `eth_chainId` against a throwaway
406    /// cache-less provider using the configured retry policy.
407    async fn resolve_chain_id(&self, url: reqwest::Url) -> Result<u64> {
408        let url_str = url.as_str().to_string();
409        let bare = build_bare_op_provider(self.build_retry_client(url));
410        bare.get_chain_id().await.map_err(|e| {
411            EvmeError::RpcError(format!("Failed to fetch chain ID from '{}': {}", url_str, e))
412        })
413    }
414}
415
416/// `clap` value parser that rejects empty and whitespace-only path arguments
417/// at parse time.
418///
419/// Without this check, `--rpc.cache-dir ""` would pass clap, land in
420/// [`resolve_cache_path`] as `PathBuf::from("")`, and silently write the
421/// per-chain file to the process's current working directory — a surprising
422/// footgun, since the same command run from different cwds would produce
423/// different cache files with no visible indication.
424fn parse_non_empty_path(s: &str) -> std::result::Result<PathBuf, String> {
425    if s.trim().is_empty() {
426        Err("path must not be empty".to_string())
427    } else {
428        Ok(PathBuf::from(s))
429    }
430}
431
432/// Build a cache-less [`OpProvider`] from an already-configured `RpcClient`.
433///
434/// Used by the cache-disabled fast path and by the throwaway chain-id fetch.
435/// The cache-enabled path builds its provider inline because the cache layer
436/// has to be inserted into the `ProviderBuilder` chain before the client is
437/// attached.
438fn build_bare_op_provider(client: RpcClient) -> OpProvider {
439    DynProvider::new(
440        ProviderBuilder::new()
441            .disable_recommended_fillers()
442            .network::<op_alloy_network::Optimism>()
443            .connect_client(client),
444    )
445}
446
447/// Resolve the absolute cache file path for `chain_id`.
448///
449/// If the user passed `--rpc.cache-dir` we use it verbatim. Otherwise we fall back to
450/// the platform cache directory (via `dirs::cache_dir()`): `$XDG_CACHE_HOME/mega-evme/rpc`
451/// on Linux, `~/Library/Caches/mega-evme/rpc` on macOS, `%LOCALAPPDATA%\mega-evme\rpc` on
452/// Windows. If the platform has no cache directory we error out and ask the user to
453/// either pass `--rpc.cache-dir` or `--rpc.no-cache-file`.
454fn resolve_cache_path(user_cache_dir: Option<&Path>, chain_id: u64) -> Result<PathBuf> {
455    let dir = match user_cache_dir {
456        Some(dir) => dir.to_path_buf(),
457        None => dirs::cache_dir()
458            .ok_or_else(|| {
459                EvmeError::RpcError(
460                    "Could not determine default cache directory; pass --rpc.cache-dir \
461                     or --rpc.no-cache-file"
462                        .to_string(),
463                )
464            })?
465            .join("mega-evme")
466            .join("rpc"),
467    };
468    Ok(dir.join(format!("rpc-cache-{chain_id}.json")))
469}
470
471#[cfg(test)]
472mod tests {
473    use std::path::PathBuf;
474
475    use super::*;
476
477    /// Explicit `--rpc.cache-dir` is used verbatim; the file is `<chain_id>.json` inside it.
478    #[test]
479    fn test_resolve_cache_path_with_explicit_dir() {
480        let dir = PathBuf::from("/some/user/dir");
481        // 4326 = MegaETH mainnet.
482        let path = resolve_cache_path(Some(&dir), 4326).expect("resolve");
483        assert_eq!(path, PathBuf::from("/some/user/dir/rpc-cache-4326.json"));
484    }
485
486    /// No explicit dir falls back to `dirs::cache_dir()` on platforms that have one.
487    /// The path ends in `mega-evme/rpc/rpc-cache-<chain_id>.json`.
488    #[test]
489    fn test_resolve_cache_path_default_is_under_platform_cache() {
490        let Some(expected_root) = dirs::cache_dir() else {
491            // Skip on exotic platforms where no cache directory is available.
492            return;
493        };
494        let path = resolve_cache_path(None, 11_155_420).expect("resolve");
495        let expected = expected_root.join("mega-evme").join("rpc").join("rpc-cache-11155420.json");
496        assert_eq!(path, expected);
497    }
498}