use std::{
collections::HashMap,
path::PathBuf,
sync::{Arc, RwLock},
};
use alloy_json_rpc::{RequestPacket, ResponsePacket};
use alloy_primitives::{keccak256, B256};
use alloy_provider::transport::{TransportError, TransportErrorKind};
use alloy_transport::TransportFut;
use serde::{Deserialize, Serialize};
use crate::common::{EvmeError, Result};
#[derive(Debug, Serialize, Deserialize)]
struct TransportCacheEntry {
key: B256,
value: String,
}
#[derive(Debug, Clone, Default)]
pub(super) struct TransportCache {
entries: Arc<RwLock<HashMap<B256, String>>>,
}
impl TransportCache {
pub(super) fn new() -> Self {
Self::default()
}
fn get(&self, key: &B256) -> Option<String> {
self.entries.read().expect("cache lock poisoned").get(key).cloned()
}
fn put(&self, key: B256, value: String) {
self.entries.write().expect("cache lock poisoned").insert(key, value);
}
pub(super) fn len(&self) -> usize {
self.entries.read().expect("cache lock poisoned").len()
}
pub(super) fn merge(&self, value: &serde_json::Value) -> Result<()> {
let entries: Vec<TransportCacheEntry> =
serde_json::from_value(value.clone()).map_err(|e| {
EvmeError::RpcError(format!("Failed to parse transport cache entries: {e}"))
})?;
let mut map = self.entries.write().expect("cache lock poisoned");
for entry in entries {
map.entry(entry.key).or_insert(entry.value);
}
Ok(())
}
fn try_serve(
&self,
key: &B256,
request_id: alloy_json_rpc::Id,
) -> Option<TransportFut<'static>> {
let cached = self.get(key)?;
Some(Box::pin(async move {
let mut resp: alloy_json_rpc::Response =
serde_json::from_str(&cached).map_err(TransportErrorKind::custom)?;
resp.id = request_id;
Ok(ResponsePacket::Single(resp))
}))
}
pub(super) fn to_value(&self) -> serde_json::Value {
let mut entries: Vec<TransportCacheEntry> = self
.entries
.read()
.expect("cache lock poisoned")
.iter()
.map(|(k, v)| TransportCacheEntry { key: *k, value: v.clone() })
.collect();
entries.sort_by_key(|e| e.key);
serde_json::to_value(entries).expect("TransportCacheEntry is always serializable")
}
pub(super) fn from_value(value: &serde_json::Value) -> Result<Self> {
let entries: Vec<TransportCacheEntry> =
serde_json::from_value(value.clone()).map_err(|e| {
EvmeError::RpcError(format!("Failed to parse transport cache entries: {e}"))
})?;
let cache = Self::new();
{
let mut map = cache.entries.write().expect("cache lock poisoned");
for entry in entries {
map.insert(entry.key, entry.value);
}
}
Ok(cache)
}
}
fn transport_cache_key(method: &str, params: Option<&serde_json::value::RawValue>) -> B256 {
let params_str = params.map_or("null", |p| p.get());
keccak256(format!("{method}\x00{params_str}"))
}
#[derive(Debug, Clone)]
pub(super) struct CachingTransport<T> {
inner: T,
cache: TransportCache,
}
impl<T> CachingTransport<T> {
pub(super) fn new(inner: T, cache: TransportCache) -> Self {
Self { inner, cache }
}
}
impl<T> tower::Service<RequestPacket> for CachingTransport<T>
where
T: tower::Service<
RequestPacket,
Response = ResponsePacket,
Error = TransportError,
Future = TransportFut<'static>,
> + Send
+ Sync
+ Clone
+ 'static,
{
type Response = ResponsePacket;
type Error = TransportError;
type Future = TransportFut<'static>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::result::Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: RequestPacket) -> Self::Future {
if let RequestPacket::Single(ref r) = req {
let key = transport_cache_key(r.method(), r.params());
if let Some(fut) = self.cache.try_serve(&key, r.id().clone()) {
return fut;
}
let cache = self.cache.clone();
let method = r.method().to_string();
let fut = self.inner.call(req);
return Box::pin(async move {
let response = fut.await?;
if let ResponsePacket::Single(ref resp) = response {
if resp.is_error() {
tracing::warn!(
method = %method,
"Skipping cache for JSON-RPC error response",
);
} else if let Ok(serialized) = serde_json::to_string(resp) {
cache.put(key, serialized);
}
}
Ok(response)
});
}
self.inner.call(req)
}
}
#[derive(Debug, Clone)]
pub(super) struct ReplayTransport {
cache_file_path: PathBuf,
cache: TransportCache,
}
impl ReplayTransport {
pub(super) fn new(cache_file_path: PathBuf, cache: TransportCache) -> Self {
Self { cache_file_path, cache }
}
}
impl tower::Service<RequestPacket> for ReplayTransport {
type Response = ResponsePacket;
type Error = TransportError;
type Future = TransportFut<'static>;
fn poll_ready(
&mut self,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::result::Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, req: RequestPacket) -> Self::Future {
if let RequestPacket::Single(ref r) = req {
let key = transport_cache_key(r.method(), r.params());
if let Some(fut) = self.cache.try_serve(&key, r.id().clone()) {
return fut;
}
let path = self.cache_file_path.display().to_string();
let method = r.method().to_string();
let params = r.params().map_or_else(|| "null".to_string(), |p| p.get().to_string());
let msg = format!(
"cache miss in offline replay file '{path}': method={method}, params={params}\n\
hint: re-capture with `mega-evme replay <tx> --rpc <URL> --rpc.capture-file {path}`"
);
return Box::pin(async move { Err(TransportErrorKind::custom_str(&msg)) });
}
Box::pin(async { Err(TransportErrorKind::backend_gone()) })
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[tokio::test]
async fn test_replay_transport_cache_miss() {
use tower::Service;
let mut transport =
ReplayTransport::new(PathBuf::from("/tmp/test.cache.json"), TransportCache::new());
let waker = std::task::Waker::noop();
let mut cx = std::task::Context::from_waker(waker);
assert!(transport.poll_ready(&mut cx).is_ready());
let req =
alloy_json_rpc::Request::new("eth_blockNumber", alloy_json_rpc::Id::Number(1), ())
.serialize()
.expect("serialize request");
let result = transport.call(RequestPacket::Single(req)).await;
assert!(result.is_err(), "cache miss must error");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("cache miss"), "error should say 'cache miss': {msg}");
assert!(msg.contains("eth_blockNumber"), "error should include method: {msg}");
assert!(msg.contains("test.cache.json"), "error should include fixture path: {msg}");
}
}