1use 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 "ethereum.reth.rs/rpc",
52 ],
53);
54shuffled_list!(
55 HTTP_DOMAINS,
56 vec![
57 "ethereum.reth.rs/rpc",
59 ],
60);
61shuffled_list!(
62 WS_ARCHIVE_DOMAINS,
63 vec![
64 "ethereum.reth.rs/ws",
66 ],
67);
68shuffled_list!(
69 WS_DOMAINS,
70 vec![
71 "ethereum.reth.rs/ws",
73 ],
74);
75
76shuffled_list!(
83 ARBITRUM_URLS,
84 vec![
85 "https://arb-pokt.nodies.app",
87 "https://arbitrum.gateway.tenderly.co",
88 ],
89);
90
91shuffled_list!(
93 DRPC_KEYS,
94 vec![
95 "Agc9NK9-6UzYh-vQDDM80Tv0A5UnBkUR8I3qssvAG40d",
96 "AjUPUPonSEInt2CZ_7A-ai3hMyxxBlsR8I4EssvAG40d",
97 ],
98);
99
100shuffled_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
116pub 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
136pub fn next_http_rpc_endpoint() -> String {
140 next_rpc_endpoint(NamedChain::Mainnet)
141}
142
143pub fn next_ws_rpc_endpoint() -> String {
147 next_ws_endpoint(NamedChain::Mainnet)
148}
149
150pub fn next_rpc_endpoint(chain: NamedChain) -> String {
152 next_url(false, chain)
153}
154
155pub fn next_ws_endpoint(chain: NamedChain) -> String {
157 next_url(true, chain)
158}
159
160pub fn next_http_archive_rpc_url() -> String {
162 next_archive_url(false)
163}
164
165pub fn next_ws_archive_rpc_url() -> String {
167 next_archive_url(true)
168}
169
170fn 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
178pub 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 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 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
257fn env_rpc_url(var: &str) -> Option<String> {
259 env::var(var).ok().filter(|url| !url.is_empty())
260}
261
262fn 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
275pub 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
308pub 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
328pub 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
346pub 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
364pub 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
382pub 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}