Skip to main content

foundry_test_utils/
rpc.rs

1//! RPC testing utilities.
2
3use alloy_primitives::B256;
4use axum::{
5    Json, Router,
6    body::Bytes,
7    http::{HeaderMap, StatusCode},
8    response::IntoResponse,
9    routing::post,
10};
11use foundry_config::{
12    NamedChain::{
13        self, Arbitrum, Base, BinanceSmartChainTestnet, Celo, Gnosis, Hyperliquid, Mainnet,
14        Optimism, Polygon, Robinhood, Sepolia,
15    },
16    RpcEndpointUrl, RpcEndpoints,
17};
18use rand::seq::SliceRandom;
19use serde_json::{Value, json};
20use std::{
21    env,
22    sync::{
23        Arc, LazyLock, Mutex,
24        atomic::{AtomicBool, AtomicUsize, Ordering},
25    },
26};
27use tokio::sync::Notify;
28
29macro_rules! shuffled_list {
30    ($name:ident, $e:expr $(,)?) => {
31        static $name: LazyLock<ShuffledList<&'static str>> =
32            LazyLock::new(|| ShuffledList::new($e));
33    };
34}
35
36struct ShuffledList<T> {
37    list: Vec<T>,
38    index: AtomicUsize,
39}
40
41impl<T> ShuffledList<T> {
42    fn new(mut list: Vec<T>) -> Self {
43        assert!(!list.is_empty());
44        list.shuffle(&mut rand::rng());
45        Self { list, index: AtomicUsize::new(0) }
46    }
47
48    fn next(&self) -> &T {
49        let index = self.index.fetch_add(1, Ordering::Relaxed);
50        &self.list[index % self.list.len()]
51    }
52}
53
54shuffled_list!(
55    HTTP_ARCHIVE_DOMAINS,
56    vec![
57        //
58        "ethereum.reth.rs/rpc",
59    ],
60);
61shuffled_list!(
62    HTTP_DOMAINS,
63    vec![
64        //
65        "ethereum.reth.rs/rpc",
66    ],
67);
68shuffled_list!(
69    WS_ARCHIVE_DOMAINS,
70    vec![
71        //
72        "ethereum.reth.rs/ws",
73    ],
74);
75shuffled_list!(
76    WS_DOMAINS,
77    vec![
78        //
79        "ethereum.reth.rs/ws",
80    ],
81);
82
83// Public Arbitrum endpoints, rotated so that a retry reaches a different provider.
84//
85// Every entry must serve archive state: `fork::flaky_test_arb_fork_mining` forks at a pinned block
86// far behind the head, which non-archive endpoints such as `arb1.arbitrum.io` reject with
87// `missing trie node`. The DRPC keys used for the other chains do not qualify: their Arbitrum quota
88// is exhausted and every fork of it fails.
89shuffled_list!(
90    ARBITRUM_URLS,
91    vec![
92        //
93        "https://arb-pokt.nodies.app",
94        "https://arbitrum.gateway.tenderly.co",
95    ],
96);
97
98// List of general purpose DRPC keys to rotate through
99shuffled_list!(
100    DRPC_KEYS,
101    vec![
102        "Agc9NK9-6UzYh-vQDDM80Tv0A5UnBkUR8I3qssvAG40d",
103        "AjUPUPonSEInt2CZ_7A-ai3hMyxxBlsR8I4EssvAG40d",
104    ],
105);
106
107// List of etherscan keys.
108shuffled_list!(
109    ETHERSCAN_KEYS,
110    vec![
111        "MCAUM7WPE9XP5UQMZPCKIBUJHPM1C24FP6",
112        "JW6RWCG2C5QF8TANH4KC7AYIF1CX7RB5D1",
113        "ZSMDY6BI2H55MBE3G9CUUQT4XYUDBB6ZSK",
114        "4FYHTY429IXYMJNS4TITKDMUKW5QRYDX61",
115        "QYKNT5RHASZ7PGQE68FNQWH99IXVTVVD2I",
116        "VXMQ117UN58Y4RHWUB8K1UGCEA7UQEWK55",
117        "C7I2G4JTA5EPYS42Z8IZFEIMQNI5GXIJEV",
118        "A15KZUMZXXCK1P25Y1VP1WGIVBBHIZDS74",
119        "3IA6ASNQXN8WKN7PNFX7T72S9YG56X9FPG",
120    ],
121);
122
123/// the RPC endpoints used during tests
124pub fn rpc_endpoints() -> RpcEndpoints {
125    RpcEndpoints::new([
126        ("mainnet", RpcEndpointUrl::Url(next_http_archive_rpc_url())),
127        ("mainnet2", RpcEndpointUrl::Url(next_http_archive_rpc_url())),
128        ("sepolia", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Sepolia))),
129        ("optimism", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Optimism))),
130        ("base", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Base))),
131        ("arbitrum", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Arbitrum))),
132        ("polygon", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Polygon))),
133        ("bsc", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::BinanceSmartChain))),
134        ("avaxTestnet", RpcEndpointUrl::Url("https://api.avax-test.network/ext/bc/C/rpc".into())),
135        ("moonbeam", RpcEndpointUrl::Url("https://moonbeam.api.onfinality.io/public".into())),
136        ("polkadotTestnet", RpcEndpointUrl::Url("https://eth-rpc-testnet.polkadot.io".into())),
137        ("kusama", RpcEndpointUrl::Url("https://eth-rpc-kusama.polkadot.io".into())),
138        ("polkadot", RpcEndpointUrl::Url("https://eth-rpc.polkadot.io".into())),
139        ("rpcEnvAlias", RpcEndpointUrl::Env("${RPC_ENV_ALIAS}".into())),
140    ])
141}
142
143/// Returns the next _mainnet_ rpc URL in inline
144///
145/// This will rotate all available rpc endpoints
146pub fn next_http_rpc_endpoint() -> String {
147    next_rpc_endpoint(NamedChain::Mainnet)
148}
149
150/// Returns the next _mainnet_ rpc URL in inline
151///
152/// This will rotate all available rpc endpoints
153pub fn next_ws_rpc_endpoint() -> String {
154    next_ws_endpoint(NamedChain::Mainnet)
155}
156
157/// Returns the next HTTP RPC URL.
158pub fn next_rpc_endpoint(chain: NamedChain) -> String {
159    next_url(false, chain)
160}
161
162/// Returns the HTTP RPC URL used to fork Tempo mainnet.
163///
164/// Set `TEMPO_MAINNET_RPC_URL`, the variable the Tempo CI workflows already use, to route the
165/// fork tests through a private endpoint; the public one applies rate limits.
166pub fn next_tempo_mainnet_rpc_endpoint() -> String {
167    let url =
168        env_rpc_url("TEMPO_MAINNET_RPC_URL").unwrap_or_else(|| "https://rpc.tempo.xyz".to_string());
169    test_debug!("next_tempo_mainnet_rpc_endpoint() = {}", debug_url(&url));
170    url
171}
172
173/// Returns the HTTP RPC URL used to fork Tempo testnet.
174///
175/// Set `TEMPO_TESTNET_RPC_URL` to use a private archive endpoint instead of the public one.
176pub fn next_tempo_testnet_rpc_endpoint() -> String {
177    let url = env_rpc_url("TEMPO_TESTNET_RPC_URL")
178        .unwrap_or_else(|| "https://rpc.moderato.tempo.xyz".to_string());
179    test_debug!("next_tempo_testnet_rpc_endpoint() = {}", debug_url(&url));
180    url
181}
182
183/// Returns the next WS RPC URL.
184pub fn next_ws_endpoint(chain: NamedChain) -> String {
185    next_url(true, chain)
186}
187
188/// Returns an HTTP URL that has access to archive state
189pub fn next_http_archive_rpc_url() -> String {
190    next_archive_url(false)
191}
192
193/// Returns a websocket URL that has access to archive state
194pub fn next_ws_archive_rpc_url() -> String {
195    next_archive_url(true)
196}
197
198/// Returns a URL that has access to archive state.
199fn next_archive_url(is_ws: bool) -> String {
200    let domain = if is_ws { &WS_ARCHIVE_DOMAINS } else { &HTTP_ARCHIVE_DOMAINS }.next();
201    let url = if is_ws { format!("wss://{domain}") } else { format!("https://{domain}") };
202    test_debug!("next_archive_url(is_ws={is_ws}) = {}", debug_url(&url));
203    url
204}
205
206/// Returns the next etherscan api key.
207pub fn next_etherscan_api_key() -> String {
208    let mut key = env::var("ETHERSCAN_KEY").unwrap_or_default();
209    if key.is_empty() {
210        key = ETHERSCAN_KEYS.next().to_string();
211    }
212    test_debug!("next_etherscan_api_key() = {}...", &key[..6]);
213    key
214}
215
216fn next_url(is_ws: bool, chain: NamedChain) -> String {
217    let url = next_url_inner(is_ws, chain);
218    test_debug!("next_url(is_ws={is_ws}, chain={chain:?}) = {}", debug_url(&url));
219    url
220}
221
222fn next_url_inner(is_ws: bool, chain: NamedChain) -> String {
223    if matches!(chain, Base) {
224        return "https://mainnet.base.org".to_string();
225    }
226
227    if matches!(chain, Optimism) {
228        return "https://mainnet.optimism.io".to_string();
229    }
230
231    if matches!(chain, BinanceSmartChainTestnet) {
232        return "https://bsc-testnet.bnbchain.org".to_string();
233    }
234
235    if matches!(chain, Celo) {
236        // Not `celo.drpc.org`: it load balances across upstreams that disagree on the chain head,
237        // so a fork of it regularly fails to fetch the block it just resolved.
238        return env_rpc_url("CELO_RPC").unwrap_or_else(|| "https://forno.celo.org".to_string());
239    }
240
241    if matches!(chain, Gnosis) {
242        return env_rpc_url("GNOSIS_RPC")
243            .unwrap_or_else(|| "https://rpc.gnosischain.com".to_string());
244    }
245
246    if matches!(chain, Hyperliquid) {
247        return env_rpc_url("HYPERLIQUID_RPC")
248            .unwrap_or_else(|| "https://rpc.hyperliquid.xyz/evm".to_string());
249    }
250
251    if matches!(chain, Robinhood) {
252        return env_rpc_url("ROBINHOOD_RPC")
253            .unwrap_or_else(|| "https://rpc.mainnet.chain.robinhood.com".to_string());
254    }
255
256    if matches!(chain, Sepolia) {
257        if let Some(rpc_url) = env_rpc_url("ETH_SEPOLIA_RPC") {
258            return rpc_url;
259        }
260        return "https://ethereum-sepolia-rpc.publicnode.com".to_string();
261    }
262
263    if matches!(chain, Arbitrum) {
264        return env_rpc_url("ARBITRUM_RPC").unwrap_or_else(|| (*ARBITRUM_URLS.next()).to_string());
265    }
266
267    let reth_works = true;
268    let domain = if reth_works && matches!(chain, Mainnet) {
269        *(if is_ws { &WS_DOMAINS } else { &HTTP_DOMAINS }).next()
270    } else {
271        // DRPC for other networks used in tests.
272        let key = DRPC_KEYS.next();
273        let network = match chain {
274            Mainnet => "ethereum",
275            Polygon => "polygon",
276            Sepolia => "sepolia",
277            _ => "",
278        };
279        &format!("lb.drpc.org/ogrpc?network={network}&dkey={key}")
280    };
281
282    if is_ws { format!("wss://{domain}") } else { format!("https://{domain}") }
283}
284
285/// Returns the RPC URL configured in the `var` environment variable, if it is set and non-empty.
286fn env_rpc_url(var: &str) -> Option<String> {
287    env::var(var).ok().filter(|url| !url.is_empty())
288}
289
290/// Basic redaction for debugging RPC URLs.
291fn debug_url(url: &str) -> impl std::fmt::Display + '_ {
292    let url = reqwest::Url::parse(url).unwrap();
293    format!(
294        "{scheme}://{host}{path}",
295        scheme = url.scheme(),
296        host = url.host_str().unwrap(),
297        path = url.path().get(..8).unwrap_or(url.path()),
298    )
299}
300
301const MONAD_SYSTEM_ADDRESS: &str = "0x6f49a8f621353f12378d0046e7d7e4b9b249dc9e";
302
303/// Spawns an RPC proxy that presents one transaction as a canonical Monad protocol envelope.
304pub async fn spawn_canonical_monad_system_rpc(endpoint: String, target_hash: B256) -> String {
305    let target_hash = target_hash.to_string();
306    let client = reqwest::Client::new();
307    let router = Router::new().route(
308        "/",
309        post(move |Json(request): Json<Value>| {
310            let client = client.clone();
311            let endpoint = endpoint.clone();
312            let target_hash = target_hash.clone();
313            async move {
314                let mut response = client
315                    .post(endpoint)
316                    .json(&request)
317                    .send()
318                    .await
319                    .unwrap()
320                    .json::<Value>()
321                    .await
322                    .unwrap();
323
324                canonicalize_monad_system_response(&request, &mut response, &target_hash);
325
326                Json(response)
327            }
328        }),
329    );
330    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
331    let address = listener.local_addr().unwrap();
332    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
333    format!("http://{address}")
334}
335
336/// Spawns an RPC proxy that rejects `method` after forwarding `successful_calls` requests.
337///
338/// Rejections use an HTTP 403 response with a vendor-specific JSON-RPC error code. This models
339/// gateways that deny unknown or custom methods without using the standard method-not-found code.
340pub async fn spawn_rpc_proxy_rejecting_method_after(
341    endpoint: String,
342    method: &'static str,
343    successful_calls: usize,
344) -> String {
345    spawn_rpc_proxy_rejecting_method(
346        endpoint,
347        method,
348        RpcMethodRejection::After(successful_calls),
349        StatusCode::FORBIDDEN,
350        -32004,
351        "method is not allowed",
352    )
353    .await
354}
355
356/// Spawns an RPC proxy whose rejection of `method` can be enabled after startup.
357pub async fn spawn_rpc_proxy_rejecting_method_when_enabled(
358    endpoint: String,
359    method: &'static str,
360) -> (String, Arc<AtomicBool>) {
361    let enabled = Arc::new(AtomicBool::new(false));
362    let proxy = spawn_rpc_proxy_rejecting_method(
363        endpoint,
364        method,
365        RpcMethodRejection::Enabled(enabled.clone()),
366        StatusCode::FORBIDDEN,
367        -32004,
368        "method is not allowed",
369    )
370    .await;
371    (proxy, enabled)
372}
373
374/// Spawns an RPC proxy that forwards `successful_calls` requests to `method`, then rejects later
375/// requests while the returned switch is enabled.
376pub async fn spawn_rpc_proxy_rejecting_method_after_when_enabled(
377    endpoint: String,
378    method: &'static str,
379    successful_calls: usize,
380) -> (String, Arc<AtomicBool>) {
381    let enabled = Arc::new(AtomicBool::new(true));
382    let proxy = spawn_rpc_proxy_rejecting_method(
383        endpoint,
384        method,
385        RpcMethodRejection::AfterWhenEnabled(successful_calls, enabled.clone()),
386        StatusCode::FORBIDDEN,
387        -32004,
388        "method is not allowed",
389    )
390    .await;
391    (proxy, enabled)
392}
393
394/// Spawns an RPC proxy that returns method-not-found for the first `unavailable_calls` requests to
395/// `method`.
396pub async fn spawn_rpc_proxy_method_not_found_before(
397    endpoint: String,
398    method: &'static str,
399    unavailable_calls: usize,
400) -> String {
401    spawn_rpc_proxy_rejecting_method(
402        endpoint,
403        method,
404        RpcMethodRejection::Before(unavailable_calls),
405        StatusCode::OK,
406        -32601,
407        "method not found",
408    )
409    .await
410}
411
412/// Spawns an RPC proxy that returns a JSON-RPC internal error for `method` after forwarding
413/// `successful_calls` requests.
414pub async fn spawn_rpc_proxy_internal_error_after(
415    endpoint: String,
416    method: &'static str,
417    successful_calls: usize,
418) -> String {
419    spawn_rpc_proxy_rejecting_method(
420        endpoint,
421        method,
422        RpcMethodRejection::After(successful_calls),
423        StatusCode::OK,
424        -32603,
425        "internal error",
426    )
427    .await
428}
429
430/// Spawns an RPC proxy that answers `method` with `result` instead of forwarding it upstream.
431///
432/// All other methods are forwarded. The returned counter tracks how many `method` calls reached the
433/// proxy, which lets tests assert that a request was never sent upstream.
434pub async fn spawn_rpc_proxy_canned_method(
435    endpoint: String,
436    method: &'static str,
437    result: Value,
438) -> (String, Arc<AtomicUsize>) {
439    let client = reqwest::Client::new();
440    let calls = Arc::new(AtomicUsize::new(0));
441    let proxy_calls = calls.clone();
442    let router = Router::new().route(
443        "/",
444        post(move |Json(request): Json<Value>| {
445            let client = client.clone();
446            let endpoint = endpoint.clone();
447            let calls = proxy_calls.clone();
448            let result = result.clone();
449            async move {
450                if request.get("method").and_then(Value::as_str) == Some(method) {
451                    calls.fetch_add(1, Ordering::Relaxed);
452                    let id = request.get("id").cloned().unwrap_or(Value::Null);
453                    return Json(json!({
454                        "jsonrpc": "2.0",
455                        "id": id,
456                        "result": result,
457                    }));
458                }
459
460                let response = client
461                    .post(endpoint)
462                    .json(&request)
463                    .send()
464                    .await
465                    .unwrap()
466                    .json::<Value>()
467                    .await
468                    .unwrap();
469                Json(response)
470            }
471        }),
472    );
473    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
474    let address = listener.local_addr().unwrap();
475    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
476    (format!("http://{address}"), calls)
477}
478
479/// Spawns an RPC proxy that reports the first transaction of every full block under `tx_type`.
480///
481/// Chains anvil can fork but not execute, such as Arbitrum and its Orbit rollups, open their
482/// blocks with a system transaction of a type Foundry does not model. This reproduces that shape
483/// on top of any endpoint, without depending on a public archive node.
484pub async fn spawn_rpc_proxy_retyping_first_block_transaction(
485    endpoint: String,
486    tx_type: &'static str,
487) -> String {
488    let client = reqwest::Client::new();
489    let router = Router::new().route(
490        "/",
491        post(move |Json(request): Json<Value>| {
492            let client = client.clone();
493            let endpoint = endpoint.clone();
494            async move {
495                let mut response = client
496                    .post(endpoint)
497                    .json(&request)
498                    .send()
499                    .await
500                    .unwrap()
501                    .json::<Value>()
502                    .await
503                    .unwrap();
504                let responses = match response.as_array_mut() {
505                    Some(batch) => batch.iter_mut().collect::<Vec<_>>(),
506                    None => vec![&mut response],
507                };
508                for response in responses {
509                    if let Some(transactions) = response
510                        .get_mut("result")
511                        .and_then(|result| result.get_mut("transactions"))
512                        .and_then(Value::as_array_mut)
513                        && let Some(first) = transactions.first_mut().and_then(Value::as_object_mut)
514                    {
515                        first.insert("type".to_string(), Value::from(tx_type));
516                    }
517                }
518                Json(response).into_response()
519            }
520        }),
521    );
522    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
523    let address = listener.local_addr().unwrap();
524    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
525    format!("http://{address}")
526}
527
528/// Spawns an RPC proxy that forwards every request upstream and passes each `method` result,
529/// together with the request params, through `map` before returning it.
530pub async fn spawn_rpc_proxy_mapping_method(
531    endpoint: String,
532    method: &'static str,
533    map: impl Fn(&Value, Value) -> Value + Send + Sync + 'static,
534) -> String {
535    let client = reqwest::Client::new();
536    let map = Arc::new(map);
537    let router = Router::new().route(
538        "/",
539        post(move |Json(request): Json<Value>| {
540            let client = client.clone();
541            let endpoint = endpoint.clone();
542            let map = map.clone();
543            async move {
544                let mut response = client
545                    .post(endpoint)
546                    .json(&request)
547                    .send()
548                    .await
549                    .unwrap()
550                    .json::<Value>()
551                    .await
552                    .unwrap();
553                if request.get("method").and_then(Value::as_str) == Some(method)
554                    && let Some(result) = response.get_mut("result")
555                {
556                    let params = request.get("params").cloned().unwrap_or(Value::Null);
557                    *result = map(&params, result.take());
558                }
559                Json(response)
560            }
561        }),
562    );
563    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
564    let address = listener.local_addr().unwrap();
565    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
566    format!("http://{address}")
567}
568
569/// Spawns an RPC proxy that records the params of every request to `method` before forwarding it.
570pub async fn spawn_rpc_proxy_recording_method(
571    endpoint: String,
572    method: &'static str,
573) -> (String, Arc<Mutex<Vec<Value>>>) {
574    let client = reqwest::Client::new();
575    let requests = Arc::new(Mutex::new(Vec::new()));
576    let router = Router::new().route(
577        "/",
578        post({
579            let requests = requests.clone();
580            move |Json(request): Json<Value>| {
581                let client = client.clone();
582                let endpoint = endpoint.clone();
583                let requests = requests.clone();
584                async move {
585                    if request.get("method").and_then(Value::as_str) == Some(method) {
586                        requests
587                            .lock()
588                            .unwrap()
589                            .push(request.get("params").cloned().unwrap_or(Value::Null));
590                    }
591                    let response = client
592                        .post(endpoint)
593                        .json(&request)
594                        .send()
595                        .await
596                        .unwrap()
597                        .json::<Value>()
598                        .await
599                        .unwrap();
600                    Json(response)
601                }
602            }
603        }),
604    );
605    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
606    let address = listener.local_addr().unwrap();
607    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
608    (format!("http://{address}"), requests)
609}
610
611/// Spawns an RPC proxy that forwards requests containing the required header and rejects all
612/// others.
613pub async fn spawn_rpc_proxy_requiring_header(
614    endpoint: String,
615    header_name: &'static str,
616    header_value: &'static str,
617) -> String {
618    let client = reqwest::Client::new();
619    let router = Router::new().route(
620        "/",
621        post(move |headers: HeaderMap, Json(request): Json<Value>| {
622            let client = client.clone();
623            let endpoint = endpoint.clone();
624            async move {
625                if headers.get(header_name).and_then(|value| value.to_str().ok())
626                    != Some(header_value)
627                {
628                    let id = request.get("id").cloned().unwrap_or(Value::Null);
629                    return (
630                        StatusCode::UNAUTHORIZED,
631                        Json(json!({
632                            "jsonrpc": "2.0",
633                            "id": id,
634                            "error": {
635                                "code": -32000,
636                                "message": "unauthorized",
637                            },
638                        })),
639                    )
640                        .into_response();
641                }
642
643                let response = client
644                    .post(endpoint)
645                    .json(&request)
646                    .send()
647                    .await
648                    .unwrap()
649                    .json::<Value>()
650                    .await
651                    .unwrap();
652                Json(response).into_response()
653            }
654        }),
655    );
656    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
657    let address = listener.local_addr().unwrap();
658    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
659    format!("http://{address}")
660}
661
662/// Spawns an RPC proxy that records the params of every request to `method` and holds the first
663/// one until `release` is notified, then answers it with `503 Service Unavailable`.
664///
665/// `reached` is notified once the first request is held. When `forward` is true, that request is
666/// forwarded before it is held and the upstream response is withheld.
667pub async fn spawn_rpc_proxy_blocking_first_submission(
668    endpoint: String,
669    method: &'static str,
670    forward: bool,
671) -> (String, Arc<Mutex<Vec<Value>>>, Arc<Notify>, Arc<Notify>) {
672    let client = reqwest::Client::new();
673    let submissions = Arc::new(Mutex::new(Vec::new()));
674    let reached = Arc::new(Notify::new());
675    let release = Arc::new(Notify::new());
676    let router = Router::new().fallback({
677        let submissions = submissions.clone();
678        let reached = reached.clone();
679        let release = release.clone();
680        move |body: Bytes| {
681            let client = client.clone();
682            let endpoint = endpoint.clone();
683            let submissions = submissions.clone();
684            let reached = reached.clone();
685            let release = release.clone();
686            async move {
687                let request: Value = serde_json::from_slice(&body).unwrap();
688                let first = request.get("method").and_then(Value::as_str) == Some(method) && {
689                    let mut submissions = submissions.lock().unwrap();
690                    submissions.push(request.get("params").cloned().unwrap_or(Value::Null));
691                    submissions.len() == 1
692                };
693                let response = if first && !forward {
694                    Bytes::new()
695                } else {
696                    client
697                        .post(endpoint)
698                        .header("content-type", "application/json")
699                        .body(body)
700                        .send()
701                        .await
702                        .unwrap()
703                        .bytes()
704                        .await
705                        .unwrap()
706                };
707                if first {
708                    reached.notify_one();
709                    release.notified().await;
710                    return StatusCode::SERVICE_UNAVAILABLE.into_response();
711                }
712                response.into_response()
713            }
714        }
715    });
716    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
717    let address = listener.local_addr().unwrap();
718    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
719    (format!("http://{address}"), submissions, reached, release)
720}
721
722#[derive(Clone)]
723enum RpcMethodRejection {
724    Before(usize),
725    After(usize),
726    Enabled(Arc<AtomicBool>),
727    AfterWhenEnabled(usize, Arc<AtomicBool>),
728}
729
730impl RpcMethodRejection {
731    fn rejects(&self, call: usize) -> bool {
732        match self {
733            Self::Before(rejected_calls) => call < *rejected_calls,
734            Self::After(successful_calls) => call >= *successful_calls,
735            Self::Enabled(enabled) => enabled.load(Ordering::SeqCst),
736            Self::AfterWhenEnabled(successful_calls, enabled) => {
737                call >= *successful_calls && enabled.load(Ordering::SeqCst)
738            }
739        }
740    }
741}
742
743async fn spawn_rpc_proxy_rejecting_method(
744    endpoint: String,
745    method: &'static str,
746    rejection: RpcMethodRejection,
747    rejection_status: StatusCode,
748    error_code: i64,
749    error_message: &'static str,
750) -> String {
751    let client = reqwest::Client::new();
752    let calls = std::sync::Arc::new(AtomicUsize::new(0));
753    let router = Router::new().route(
754        "/",
755        post(move |Json(request): Json<Value>| {
756            let client = client.clone();
757            let endpoint = endpoint.clone();
758            let calls = calls.clone();
759            let rejection = rejection.clone();
760            async move {
761                if request.get("method").and_then(Value::as_str) == Some(method)
762                    && rejection.rejects(calls.fetch_add(1, Ordering::Relaxed))
763                {
764                    let id = request.get("id").cloned().unwrap_or(Value::Null);
765                    return (
766                        rejection_status,
767                        Json(json!({
768                            "jsonrpc": "2.0",
769                            "id": id,
770                            "error": {
771                                "code": error_code,
772                                "message": error_message,
773                            },
774                        })),
775                    )
776                        .into_response();
777                }
778
779                let response = client
780                    .post(endpoint)
781                    .json(&request)
782                    .send()
783                    .await
784                    .unwrap()
785                    .json::<Value>()
786                    .await
787                    .unwrap();
788                Json(response).into_response()
789            }
790        }),
791    );
792    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
793    let address = listener.local_addr().unwrap();
794    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
795    format!("http://{address}")
796}
797
798fn canonicalize_monad_system_response(request: &Value, response: &mut Value, target_hash: &str) {
799    if let Some(requests) = request.as_array() {
800        let Some(responses) = response.as_array_mut() else { return };
801        for response in responses {
802            let Some(response_id) = response.get("id") else { continue };
803            if let Some(request) =
804                requests.iter().find(|request| request.get("id") == Some(response_id))
805            {
806                canonicalize_monad_system_result(request, response, target_hash);
807            }
808        }
809    } else {
810        canonicalize_monad_system_result(request, response, target_hash);
811    }
812}
813
814fn canonicalize_monad_system_result(request: &Value, response: &mut Value, target_hash: &str) {
815    let Some(method) = request.get("method").and_then(Value::as_str) else { return };
816    let Some(result) = response.get_mut("result") else { return };
817
818    match method {
819        "eth_getTransactionByHash"
820        | "eth_getTransactionByBlockHashAndIndex"
821        | "eth_getTransactionByBlockNumberAndIndex" => {
822            canonicalize_monad_system_transaction(result, target_hash);
823        }
824        "eth_getBlockByHash" | "eth_getBlockByNumber" => {
825            if let Some(transactions) = result.get_mut("transactions").and_then(Value::as_array_mut)
826            {
827                for transaction in transactions {
828                    canonicalize_monad_system_transaction(transaction, target_hash);
829                }
830            }
831        }
832        "eth_getTransactionReceipt" => {
833            canonicalize_monad_system_receipt(result, target_hash);
834        }
835        "eth_getBlockReceipts" => {
836            if let Some(receipts) = result.as_array_mut() {
837                for receipt in receipts {
838                    canonicalize_monad_system_receipt(receipt, target_hash);
839                }
840            }
841        }
842        _ => {}
843    }
844}
845
846fn canonicalize_monad_system_transaction(transaction: &mut Value, target_hash: &str) {
847    let Some(transaction) = transaction.as_object_mut() else { return };
848    if !transaction
849        .get("hash")
850        .and_then(Value::as_str)
851        .is_some_and(|hash| hash.eq_ignore_ascii_case(target_hash))
852    {
853        return;
854    }
855
856    let tx_type = transaction.get("type").and_then(parse_rpc_quantity).unwrap_or_default();
857    let legacy_v = (tx_type != 0)
858        .then(|| {
859            let parity = transaction
860                .get("yParity")
861                .or_else(|| transaction.get("v"))
862                .and_then(parse_rpc_quantity)
863                .filter(|parity| *parity <= 1)?;
864            let v = if let Some(chain_id) = transaction.get("chainId").and_then(parse_rpc_quantity)
865            {
866                chain_id.checked_mul(2)?.checked_add(35 + parity)?
867            } else {
868                27 + parity
869            };
870            Some(format!("0x{v:x}"))
871        })
872        .flatten();
873
874    transaction.insert("from".to_string(), json!(MONAD_SYSTEM_ADDRESS));
875    transaction.insert("gas".to_string(), json!("0x0"));
876    transaction.insert("gasPrice".to_string(), json!("0x0"));
877    transaction.insert("type".to_string(), json!("0x0"));
878    if let Some(v) = legacy_v {
879        transaction.insert("v".to_string(), json!(v));
880    }
881    for field in [
882        "accessList",
883        "authorizationList",
884        "blobVersionedHashes",
885        "maxFeePerBlobGas",
886        "maxFeePerGas",
887        "maxPriorityFeePerGas",
888        "yParity",
889    ] {
890        transaction.remove(field);
891    }
892}
893
894fn parse_rpc_quantity(value: &Value) -> Option<u64> {
895    value.as_u64().or_else(|| {
896        value.as_str()?.strip_prefix("0x").and_then(|value| u64::from_str_radix(value, 16).ok())
897    })
898}
899
900fn canonicalize_monad_system_receipt(receipt: &mut Value, target_hash: &str) {
901    let Some(receipt) = receipt.as_object_mut() else { return };
902    if !receipt
903        .get("transactionHash")
904        .and_then(Value::as_str)
905        .is_some_and(|hash| hash.eq_ignore_ascii_case(target_hash))
906    {
907        return;
908    }
909
910    receipt.insert("cumulativeGasUsed".to_string(), json!("0x0"));
911    receipt.insert("effectiveGasPrice".to_string(), json!("0x0"));
912    receipt.insert("gasUsed".to_string(), json!("0x0"));
913    receipt.insert("type".to_string(), json!("0x0"));
914    receipt.remove("blobGasPrice");
915    receipt.remove("blobGasUsed");
916}
917
918#[cfg(test)]
919#[expect(clippy::disallowed_macros)]
920mod tests {
921    use super::*;
922    use alloy_primitives::address;
923    use foundry_config::Chain;
924
925    #[test]
926    fn canonical_monad_system_response_supports_batches() {
927        let target_hash = B256::with_last_byte(1).to_string();
928        let requests = json!([
929            {
930                "jsonrpc": "2.0",
931                "id": 1,
932                "method": "eth_getTransactionByHash",
933                "params": [target_hash],
934            },
935            {
936                "jsonrpc": "2.0",
937                "id": 2,
938                "method": "eth_getTransactionReceipt",
939                "params": [target_hash],
940            },
941        ]);
942        let mut responses = json!([
943            {
944                "jsonrpc": "2.0",
945                "id": 2,
946                "result": {
947                    "transactionHash": target_hash,
948                    "gasUsed": "0x5208",
949                },
950            },
951            {
952                "jsonrpc": "2.0",
953                "id": 1,
954                "result": {
955                    "hash": target_hash,
956                    "chainId": "0x7a69",
957                    "gas": "0x5208",
958                    "gasPrice": "0x1",
959                    "r": "0x1",
960                    "s": "0x1",
961                    "type": "0x2",
962                    "v": "0x1",
963                    "yParity": "0x1",
964                },
965            },
966        ]);
967
968        canonicalize_monad_system_response(&requests, &mut responses, &target_hash);
969
970        assert_eq!(responses[0]["result"]["gasUsed"], "0x0");
971        assert_eq!(responses[1]["result"]["gas"], "0x0");
972        assert_eq!(responses[1]["result"]["from"], MONAD_SYSTEM_ADDRESS);
973        assert_eq!(responses[1]["result"]["type"], "0x0");
974        assert_eq!(responses[1]["result"]["r"], "0x1");
975        assert_eq!(responses[1]["result"]["s"], "0x1");
976        assert_eq!(responses[1]["result"]["v"], "0xf4f6");
977        assert!(responses[1]["result"].get("yParity").is_none());
978    }
979
980    #[test]
981    fn canonical_monad_system_response_ignores_malformed_requests() {
982        let request = json!({"jsonrpc": "2.0", "id": 1});
983        let mut response = json!({"jsonrpc": "2.0", "id": 1, "result": "unchanged"});
984
985        canonicalize_monad_system_response(&request, &mut response, &B256::ZERO.to_string());
986
987        assert_eq!(response["result"], "unchanged");
988    }
989
990    #[tokio::test]
991    #[ignore = "run manually"]
992    async fn test_etherscan_keys() {
993        let address = address!("0xdAC17F958D2ee523a2206206994597C13D831ec7");
994        let mut first_abi = None;
995        let mut failed = Vec::new();
996        for (i, &key) in ETHERSCAN_KEYS.list.iter().enumerate() {
997            println!("trying key {i} ({key})");
998
999            let client = foundry_block_explorers::Client::builder()
1000                .chain(Chain::mainnet())
1001                .unwrap()
1002                .with_api_key(key)
1003                .build()
1004                .unwrap();
1005
1006            let mut fail = |e: &str| {
1007                eprintln!("key {i} ({key}) failed: {e}");
1008                failed.push(key);
1009            };
1010
1011            let abi = match client.contract_abi(address).await {
1012                Ok(abi) => abi,
1013                Err(e) => {
1014                    fail(&e.to_string());
1015                    continue;
1016                }
1017            };
1018
1019            if let Some(first_abi) = &first_abi {
1020                if abi != *first_abi {
1021                    fail("abi mismatch");
1022                }
1023            } else {
1024                first_abi = Some(abi);
1025            }
1026        }
1027        assert!(failed.is_empty(), "failed keys: {failed:#?}")
1028    }
1029}