Skip to main content

foundry_test_utils/
rpc.rs

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