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}