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 next WS RPC URL.
156pub fn next_ws_endpoint(chain: NamedChain) -> String {
157    next_url(true, chain)
158}
159
160/// Returns an HTTP URL that has access to archive state
161pub fn next_http_archive_rpc_url() -> String {
162    next_archive_url(false)
163}
164
165/// Returns a websocket URL that has access to archive state
166pub fn next_ws_archive_rpc_url() -> String {
167    next_archive_url(true)
168}
169
170/// Returns a URL that has access to archive state.
171fn next_archive_url(is_ws: bool) -> String {
172    let domain = if is_ws { &WS_ARCHIVE_DOMAINS } else { &HTTP_ARCHIVE_DOMAINS }.next();
173    let url = if is_ws { format!("wss://{domain}") } else { format!("https://{domain}") };
174    test_debug!("next_archive_url(is_ws={is_ws}) = {}", debug_url(&url));
175    url
176}
177
178/// Returns the next etherscan api key.
179pub fn next_etherscan_api_key() -> String {
180    let mut key = env::var("ETHERSCAN_KEY").unwrap_or_default();
181    if key.is_empty() {
182        key = ETHERSCAN_KEYS.next().to_string();
183    }
184    test_debug!("next_etherscan_api_key() = {}...", &key[..6]);
185    key
186}
187
188fn next_url(is_ws: bool, chain: NamedChain) -> String {
189    let url = next_url_inner(is_ws, chain);
190    test_debug!("next_url(is_ws={is_ws}, chain={chain:?}) = {}", debug_url(&url));
191    url
192}
193
194fn next_url_inner(is_ws: bool, chain: NamedChain) -> String {
195    if matches!(chain, Base) {
196        return "https://mainnet.base.org".to_string();
197    }
198
199    if matches!(chain, Optimism) {
200        return "https://mainnet.optimism.io".to_string();
201    }
202
203    if matches!(chain, BinanceSmartChainTestnet) {
204        return "https://bsc-testnet.bnbchain.org".to_string();
205    }
206
207    if matches!(chain, Celo) {
208        // Not `celo.drpc.org`: it load balances across upstreams that disagree on the chain head,
209        // so a fork of it regularly fails to fetch the block it just resolved.
210        return env_rpc_url("CELO_RPC").unwrap_or_else(|| "https://forno.celo.org".to_string());
211    }
212
213    if matches!(chain, Gnosis) {
214        return env_rpc_url("GNOSIS_RPC")
215            .unwrap_or_else(|| "https://rpc.gnosischain.com".to_string());
216    }
217
218    if matches!(chain, Hyperliquid) {
219        return env_rpc_url("HYPERLIQUID_RPC")
220            .unwrap_or_else(|| "https://rpc.hyperliquid.xyz/evm".to_string());
221    }
222
223    if matches!(chain, Robinhood) {
224        return env_rpc_url("ROBINHOOD_RPC")
225            .unwrap_or_else(|| "https://rpc.mainnet.chain.robinhood.com".to_string());
226    }
227
228    if matches!(chain, Sepolia) {
229        if let Some(rpc_url) = env_rpc_url("ETH_SEPOLIA_RPC") {
230            return rpc_url;
231        }
232        return "https://ethereum-sepolia-rpc.publicnode.com".to_string();
233    }
234
235    if matches!(chain, Arbitrum) {
236        return env_rpc_url("ARBITRUM_RPC").unwrap_or_else(|| (*ARBITRUM_URLS.next()).to_string());
237    }
238
239    let reth_works = true;
240    let domain = if reth_works && matches!(chain, Mainnet) {
241        *(if is_ws { &WS_DOMAINS } else { &HTTP_DOMAINS }).next()
242    } else {
243        // DRPC for other networks used in tests.
244        let key = DRPC_KEYS.next();
245        let network = match chain {
246            Mainnet => "ethereum",
247            Polygon => "polygon",
248            Sepolia => "sepolia",
249            _ => "",
250        };
251        &format!("lb.drpc.org/ogrpc?network={network}&dkey={key}")
252    };
253
254    if is_ws { format!("wss://{domain}") } else { format!("https://{domain}") }
255}
256
257/// Returns the RPC URL configured in the `var` environment variable, if it is set and non-empty.
258fn env_rpc_url(var: &str) -> Option<String> {
259    env::var(var).ok().filter(|url| !url.is_empty())
260}
261
262/// Basic redaction for debugging RPC URLs.
263fn debug_url(url: &str) -> impl std::fmt::Display + '_ {
264    let url = reqwest::Url::parse(url).unwrap();
265    format!(
266        "{scheme}://{host}{path}",
267        scheme = url.scheme(),
268        host = url.host_str().unwrap(),
269        path = url.path().get(..8).unwrap_or(url.path()),
270    )
271}
272
273const MONAD_SYSTEM_ADDRESS: &str = "0x6f49a8f621353f12378d0046e7d7e4b9b249dc9e";
274
275/// Spawns an RPC proxy that presents one transaction as a canonical Monad protocol envelope.
276pub async fn spawn_canonical_monad_system_rpc(endpoint: String, target_hash: B256) -> String {
277    let target_hash = target_hash.to_string();
278    let client = reqwest::Client::new();
279    let router = Router::new().route(
280        "/",
281        post(move |Json(request): Json<Value>| {
282            let client = client.clone();
283            let endpoint = endpoint.clone();
284            let target_hash = target_hash.clone();
285            async move {
286                let mut response = client
287                    .post(endpoint)
288                    .json(&request)
289                    .send()
290                    .await
291                    .unwrap()
292                    .json::<Value>()
293                    .await
294                    .unwrap();
295
296                canonicalize_monad_system_response(&request, &mut response, &target_hash);
297
298                Json(response)
299            }
300        }),
301    );
302    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
303    let address = listener.local_addr().unwrap();
304    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
305    format!("http://{address}")
306}
307
308/// Spawns an RPC proxy that rejects `method` after forwarding `successful_calls` requests.
309///
310/// Rejections use an HTTP 403 response with a vendor-specific JSON-RPC error code. This models
311/// gateways that deny unknown or custom methods without using the standard method-not-found code.
312pub async fn spawn_rpc_proxy_rejecting_method_after(
313    endpoint: String,
314    method: &'static str,
315    successful_calls: usize,
316) -> String {
317    spawn_rpc_proxy_rejecting_method(
318        endpoint,
319        method,
320        RpcMethodRejection::After(successful_calls),
321        StatusCode::FORBIDDEN,
322        -32004,
323        "method is not allowed",
324    )
325    .await
326}
327
328/// Spawns an RPC proxy whose rejection of `method` can be enabled after startup.
329pub async fn spawn_rpc_proxy_rejecting_method_when_enabled(
330    endpoint: String,
331    method: &'static str,
332) -> (String, Arc<AtomicBool>) {
333    let enabled = Arc::new(AtomicBool::new(false));
334    let proxy = spawn_rpc_proxy_rejecting_method(
335        endpoint,
336        method,
337        RpcMethodRejection::Enabled(enabled.clone()),
338        StatusCode::FORBIDDEN,
339        -32004,
340        "method is not allowed",
341    )
342    .await;
343    (proxy, enabled)
344}
345
346/// Spawns an RPC proxy that returns method-not-found for the first `unavailable_calls` requests to
347/// `method`.
348pub async fn spawn_rpc_proxy_method_not_found_before(
349    endpoint: String,
350    method: &'static str,
351    unavailable_calls: usize,
352) -> String {
353    spawn_rpc_proxy_rejecting_method(
354        endpoint,
355        method,
356        RpcMethodRejection::Before(unavailable_calls),
357        StatusCode::OK,
358        -32601,
359        "method not found",
360    )
361    .await
362}
363
364/// Spawns an RPC proxy that returns a JSON-RPC internal error for `method` after forwarding
365/// `successful_calls` requests.
366pub async fn spawn_rpc_proxy_internal_error_after(
367    endpoint: String,
368    method: &'static str,
369    successful_calls: usize,
370) -> String {
371    spawn_rpc_proxy_rejecting_method(
372        endpoint,
373        method,
374        RpcMethodRejection::After(successful_calls),
375        StatusCode::OK,
376        -32603,
377        "internal error",
378    )
379    .await
380}
381
382/// Spawns an RPC proxy that answers `method` with `result` instead of forwarding it upstream.
383///
384/// All other methods are forwarded. The returned counter tracks how many `method` calls reached the
385/// proxy, which lets tests assert that a request was never sent upstream.
386pub async fn spawn_rpc_proxy_canned_method(
387    endpoint: String,
388    method: &'static str,
389    result: Value,
390) -> (String, Arc<AtomicUsize>) {
391    let client = reqwest::Client::new();
392    let calls = Arc::new(AtomicUsize::new(0));
393    let proxy_calls = calls.clone();
394    let router = Router::new().route(
395        "/",
396        post(move |Json(request): Json<Value>| {
397            let client = client.clone();
398            let endpoint = endpoint.clone();
399            let calls = proxy_calls.clone();
400            let result = result.clone();
401            async move {
402                if request.get("method").and_then(Value::as_str) == Some(method) {
403                    calls.fetch_add(1, Ordering::Relaxed);
404                    let id = request.get("id").cloned().unwrap_or(Value::Null);
405                    return Json(json!({
406                        "jsonrpc": "2.0",
407                        "id": id,
408                        "result": result,
409                    }));
410                }
411
412                let response = client
413                    .post(endpoint)
414                    .json(&request)
415                    .send()
416                    .await
417                    .unwrap()
418                    .json::<Value>()
419                    .await
420                    .unwrap();
421                Json(response)
422            }
423        }),
424    );
425    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
426    let address = listener.local_addr().unwrap();
427    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
428    (format!("http://{address}"), calls)
429}
430
431#[derive(Clone)]
432enum RpcMethodRejection {
433    Before(usize),
434    After(usize),
435    Enabled(Arc<AtomicBool>),
436}
437
438impl RpcMethodRejection {
439    fn rejects(&self, call: usize) -> bool {
440        match self {
441            Self::Before(rejected_calls) => call < *rejected_calls,
442            Self::After(successful_calls) => call >= *successful_calls,
443            Self::Enabled(enabled) => enabled.load(Ordering::SeqCst),
444        }
445    }
446}
447
448async fn spawn_rpc_proxy_rejecting_method(
449    endpoint: String,
450    method: &'static str,
451    rejection: RpcMethodRejection,
452    rejection_status: StatusCode,
453    error_code: i64,
454    error_message: &'static str,
455) -> String {
456    let client = reqwest::Client::new();
457    let calls = std::sync::Arc::new(AtomicUsize::new(0));
458    let router = Router::new().route(
459        "/",
460        post(move |Json(request): Json<Value>| {
461            let client = client.clone();
462            let endpoint = endpoint.clone();
463            let calls = calls.clone();
464            let rejection = rejection.clone();
465            async move {
466                if request.get("method").and_then(Value::as_str) == Some(method)
467                    && rejection.rejects(calls.fetch_add(1, Ordering::Relaxed))
468                {
469                    let id = request.get("id").cloned().unwrap_or(Value::Null);
470                    return (
471                        rejection_status,
472                        Json(json!({
473                            "jsonrpc": "2.0",
474                            "id": id,
475                            "error": {
476                                "code": error_code,
477                                "message": error_message,
478                            },
479                        })),
480                    )
481                        .into_response();
482                }
483
484                let response = client
485                    .post(endpoint)
486                    .json(&request)
487                    .send()
488                    .await
489                    .unwrap()
490                    .json::<Value>()
491                    .await
492                    .unwrap();
493                Json(response).into_response()
494            }
495        }),
496    );
497    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
498    let address = listener.local_addr().unwrap();
499    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
500    format!("http://{address}")
501}
502
503fn canonicalize_monad_system_response(request: &Value, response: &mut Value, target_hash: &str) {
504    if let Some(requests) = request.as_array() {
505        let Some(responses) = response.as_array_mut() else { return };
506        for response in responses {
507            let Some(response_id) = response.get("id") else { continue };
508            if let Some(request) =
509                requests.iter().find(|request| request.get("id") == Some(response_id))
510            {
511                canonicalize_monad_system_result(request, response, target_hash);
512            }
513        }
514    } else {
515        canonicalize_monad_system_result(request, response, target_hash);
516    }
517}
518
519fn canonicalize_monad_system_result(request: &Value, response: &mut Value, target_hash: &str) {
520    let Some(method) = request.get("method").and_then(Value::as_str) else { return };
521    let Some(result) = response.get_mut("result") else { return };
522
523    match method {
524        "eth_getTransactionByHash"
525        | "eth_getTransactionByBlockHashAndIndex"
526        | "eth_getTransactionByBlockNumberAndIndex" => {
527            canonicalize_monad_system_transaction(result, target_hash);
528        }
529        "eth_getBlockByHash" | "eth_getBlockByNumber" => {
530            if let Some(transactions) = result.get_mut("transactions").and_then(Value::as_array_mut)
531            {
532                for transaction in transactions {
533                    canonicalize_monad_system_transaction(transaction, target_hash);
534                }
535            }
536        }
537        "eth_getTransactionReceipt" => {
538            canonicalize_monad_system_receipt(result, target_hash);
539        }
540        "eth_getBlockReceipts" => {
541            if let Some(receipts) = result.as_array_mut() {
542                for receipt in receipts {
543                    canonicalize_monad_system_receipt(receipt, target_hash);
544                }
545            }
546        }
547        _ => {}
548    }
549}
550
551fn canonicalize_monad_system_transaction(transaction: &mut Value, target_hash: &str) {
552    let Some(transaction) = transaction.as_object_mut() else { return };
553    if !transaction
554        .get("hash")
555        .and_then(Value::as_str)
556        .is_some_and(|hash| hash.eq_ignore_ascii_case(target_hash))
557    {
558        return;
559    }
560
561    let tx_type = transaction.get("type").and_then(parse_rpc_quantity).unwrap_or_default();
562    let legacy_v = (tx_type != 0)
563        .then(|| {
564            let parity = transaction
565                .get("yParity")
566                .or_else(|| transaction.get("v"))
567                .and_then(parse_rpc_quantity)
568                .filter(|parity| *parity <= 1)?;
569            let v = if let Some(chain_id) = transaction.get("chainId").and_then(parse_rpc_quantity)
570            {
571                chain_id.checked_mul(2)?.checked_add(35 + parity)?
572            } else {
573                27 + parity
574            };
575            Some(format!("0x{v:x}"))
576        })
577        .flatten();
578
579    transaction.insert("from".to_string(), json!(MONAD_SYSTEM_ADDRESS));
580    transaction.insert("gas".to_string(), json!("0x0"));
581    transaction.insert("gasPrice".to_string(), json!("0x0"));
582    transaction.insert("type".to_string(), json!("0x0"));
583    if let Some(v) = legacy_v {
584        transaction.insert("v".to_string(), json!(v));
585    }
586    for field in [
587        "accessList",
588        "authorizationList",
589        "blobVersionedHashes",
590        "maxFeePerBlobGas",
591        "maxFeePerGas",
592        "maxPriorityFeePerGas",
593        "yParity",
594    ] {
595        transaction.remove(field);
596    }
597}
598
599fn parse_rpc_quantity(value: &Value) -> Option<u64> {
600    value.as_u64().or_else(|| {
601        value.as_str()?.strip_prefix("0x").and_then(|value| u64::from_str_radix(value, 16).ok())
602    })
603}
604
605fn canonicalize_monad_system_receipt(receipt: &mut Value, target_hash: &str) {
606    let Some(receipt) = receipt.as_object_mut() else { return };
607    if !receipt
608        .get("transactionHash")
609        .and_then(Value::as_str)
610        .is_some_and(|hash| hash.eq_ignore_ascii_case(target_hash))
611    {
612        return;
613    }
614
615    receipt.insert("cumulativeGasUsed".to_string(), json!("0x0"));
616    receipt.insert("effectiveGasPrice".to_string(), json!("0x0"));
617    receipt.insert("gasUsed".to_string(), json!("0x0"));
618    receipt.insert("type".to_string(), json!("0x0"));
619    receipt.remove("blobGasPrice");
620    receipt.remove("blobGasUsed");
621}
622
623#[cfg(test)]
624#[expect(clippy::disallowed_macros)]
625mod tests {
626    use super::*;
627    use alloy_primitives::address;
628    use foundry_config::Chain;
629
630    #[test]
631    fn canonical_monad_system_response_supports_batches() {
632        let target_hash = B256::with_last_byte(1).to_string();
633        let requests = json!([
634            {
635                "jsonrpc": "2.0",
636                "id": 1,
637                "method": "eth_getTransactionByHash",
638                "params": [target_hash],
639            },
640            {
641                "jsonrpc": "2.0",
642                "id": 2,
643                "method": "eth_getTransactionReceipt",
644                "params": [target_hash],
645            },
646        ]);
647        let mut responses = json!([
648            {
649                "jsonrpc": "2.0",
650                "id": 2,
651                "result": {
652                    "transactionHash": target_hash,
653                    "gasUsed": "0x5208",
654                },
655            },
656            {
657                "jsonrpc": "2.0",
658                "id": 1,
659                "result": {
660                    "hash": target_hash,
661                    "chainId": "0x7a69",
662                    "gas": "0x5208",
663                    "gasPrice": "0x1",
664                    "r": "0x1",
665                    "s": "0x1",
666                    "type": "0x2",
667                    "v": "0x1",
668                    "yParity": "0x1",
669                },
670            },
671        ]);
672
673        canonicalize_monad_system_response(&requests, &mut responses, &target_hash);
674
675        assert_eq!(responses[0]["result"]["gasUsed"], "0x0");
676        assert_eq!(responses[1]["result"]["gas"], "0x0");
677        assert_eq!(responses[1]["result"]["from"], MONAD_SYSTEM_ADDRESS);
678        assert_eq!(responses[1]["result"]["type"], "0x0");
679        assert_eq!(responses[1]["result"]["r"], "0x1");
680        assert_eq!(responses[1]["result"]["s"], "0x1");
681        assert_eq!(responses[1]["result"]["v"], "0xf4f6");
682        assert!(responses[1]["result"].get("yParity").is_none());
683    }
684
685    #[test]
686    fn canonical_monad_system_response_ignores_malformed_requests() {
687        let request = json!({"jsonrpc": "2.0", "id": 1});
688        let mut response = json!({"jsonrpc": "2.0", "id": 1, "result": "unchanged"});
689
690        canonicalize_monad_system_response(&request, &mut response, &B256::ZERO.to_string());
691
692        assert_eq!(response["result"], "unchanged");
693    }
694
695    #[tokio::test]
696    #[ignore = "run manually"]
697    async fn test_etherscan_keys() {
698        let address = address!("0xdAC17F958D2ee523a2206206994597C13D831ec7");
699        let mut first_abi = None;
700        let mut failed = Vec::new();
701        for (i, &key) in ETHERSCAN_KEYS.list.iter().enumerate() {
702            println!("trying key {i} ({key})");
703
704            let client = foundry_block_explorers::Client::builder()
705                .chain(Chain::mainnet())
706                .unwrap()
707                .with_api_key(key)
708                .build()
709                .unwrap();
710
711            let mut fail = |e: &str| {
712                eprintln!("key {i} ({key}) failed: {e}");
713                failed.push(key);
714            };
715
716            let abi = match client.contract_abi(address).await {
717                Ok(abi) => abi,
718                Err(e) => {
719                    fail(&e.to_string());
720                    continue;
721                }
722            };
723
724            if let Some(first_abi) = &first_abi {
725                if abi != *first_abi {
726                    fail("abi mismatch");
727                }
728            } else {
729                first_abi = Some(abi);
730            }
731        }
732        assert!(failed.is_empty(), "failed keys: {failed:#?}")
733    }
734}