foundry_test_utils/
rpc.rs1use foundry_config::{
4 NamedChain,
5 NamedChain::{Arbitrum, Base, BinanceSmartChainTestnet, Mainnet, Optimism, Polygon, Sepolia},
6};
7use rand::seq::SliceRandom;
8use std::sync::{
9 LazyLock,
10 atomic::{AtomicUsize, Ordering},
11};
12
13fn shuffled<T>(mut vec: Vec<T>) -> Vec<T> {
14 vec.shuffle(&mut rand::rng());
15 vec
16}
17
18static RETH_ARCHIVE_HOSTS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
20 shuffled(vec![
21 "reth-ethereum.ithaca.xyz",
23 ])
24});
25
26static RETH_HOSTS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
28 shuffled(vec![
29 "reth-ethereum.ithaca.xyz",
31 "reth-ethereum-full.ithaca.xyz",
32 ])
33});
34
35static DRPC_KEYS: LazyLock<Vec<String>> = LazyLock::new(|| {
37 let mut keys = vec!["AgasqIYODEW_j_J0F91L8oETmhtHCXkR8JAVssvAG40d".to_owned()];
38 if let Ok(secret) = std::env::var("DLRP_API_KEY") {
40 keys.clear();
41 keys.push(secret);
42 }
43
44 keys.shuffle(&mut rand::rng());
45
46 keys
47});
48
49fn fallback_etherscan_keys() -> Vec<String> {
51 vec![
52 "MCAUM7WPE9XP5UQMZPCKIBUJHPM1C24FP6".to_string(),
53 "JW6RWCG2C5QF8TANH4KC7AYIF1CX7RB5D1".to_string(),
54 "ZSMDY6BI2H55MBE3G9CUUQT4XYUDBB6ZSK".to_string(),
55 "4FYHTY429IXYMJNS4TITKDMUKW5QRYDX61".to_string(),
56 "QYKNT5RHASZ7PGQE68FNQWH99IXVTVVD2I".to_string(),
57 "VXMQ117UN58Y4RHWUB8K1UGCEA7UQEWK55".to_string(),
58 "C7I2G4JTA5EPYS42Z8IZFEIMQNI5GXIJEV".to_string(),
59 "A15KZUMZXXCK1P25Y1VP1WGIVBBHIZDS74".to_string(),
60 "3IA6ASNQXN8WKN7PNFX7T72S9YG56X9FPG".to_string(),
61 "ZUB97R31KSYX7NYVW6224Q6EYY6U56H591".to_string(),
62 ]
63}
64
65static ETHERSCAN_KEYS: LazyLock<Vec<String>> = LazyLock::new(|| {
67 let mut keys = std::env::var("ETHERSCAN_API_KEYS")
69 .ok()
70 .map(|env_keys| {
71 env_keys
72 .split(',')
73 .map(|s| s.trim().to_string())
74 .filter(|s| !s.is_empty())
75 .collect::<Vec<String>>()
76 })
77 .filter(|keys| !keys.is_empty())
78 .unwrap_or_else(fallback_etherscan_keys);
79
80 keys.shuffle(&mut rand::rng());
81 keys
82});
83
84fn next_idx() -> usize {
86 static NEXT_INDEX: AtomicUsize = AtomicUsize::new(0);
87 NEXT_INDEX.fetch_add(1, Ordering::SeqCst)
88}
89
90fn next<T>(list: &[T]) -> &T {
92 &list[next_idx() % list.len()]
93}
94
95pub fn next_http_rpc_endpoint() -> String {
99 next_rpc_endpoint(NamedChain::Mainnet)
100}
101
102pub fn next_ws_rpc_endpoint() -> String {
106 next_ws_endpoint(NamedChain::Mainnet)
107}
108
109pub fn next_rpc_endpoint(chain: NamedChain) -> String {
111 next_url(false, chain)
112}
113
114pub fn next_ws_endpoint(chain: NamedChain) -> String {
116 next_url(true, chain)
117}
118
119pub fn next_http_archive_rpc_url() -> String {
121 next_archive_url(false)
122}
123
124pub fn next_ws_archive_rpc_url() -> String {
126 next_archive_url(true)
127}
128
129fn next_archive_url(is_ws: bool) -> String {
131 let urls = archive_urls(is_ws);
132 let url = next(urls);
133 eprintln!("--- next_archive_url(is_ws={is_ws}) = {url} ---");
134 url.clone()
135}
136
137fn archive_urls(is_ws: bool) -> &'static [String] {
138 static WS: LazyLock<Vec<String>> = LazyLock::new(|| get(true));
139 static HTTP: LazyLock<Vec<String>> = LazyLock::new(|| get(false));
140
141 fn get(is_ws: bool) -> Vec<String> {
142 let mut urls = vec![];
143
144 for &host in RETH_ARCHIVE_HOSTS.iter() {
145 if is_ws {
146 urls.push(format!("wss://{host}/ws"));
147 } else {
148 urls.push(format!("https://{host}/rpc"));
149 }
150 }
151
152 urls
153 }
154
155 if is_ws { &WS } else { &HTTP }
156}
157
158pub fn next_etherscan_api_key() -> String {
160 let key = next(ÐERSCAN_KEYS).clone();
161 eprintln!("--- next_etherscan_api_key() = {key} ---");
162 key
163}
164
165fn next_url(is_ws: bool, chain: NamedChain) -> String {
166 if matches!(chain, Base) {
167 return "https://mainnet.base.org".to_string();
168 }
169
170 if matches!(chain, Optimism) {
171 return "https://mainnet.optimism.io".to_string();
172 }
173
174 if matches!(chain, BinanceSmartChainTestnet) {
175 return "https://bsc-testnet-rpc.publicnode.com".to_string();
176 }
177
178 let domain = if matches!(chain, Mainnet) {
179 let idx = next_idx() % RETH_HOSTS.len();
181 let host = RETH_HOSTS[idx];
182 if is_ws { format!("{host}/ws") } else { format!("{host}/rpc") }
183 } else {
184 let idx = next_idx() % DRPC_KEYS.len();
186 let key = &DRPC_KEYS[idx];
187
188 let network = match chain {
189 Arbitrum => "arbitrum",
190 Polygon => "polygon",
191 Sepolia => "sepolia",
192 _ => "",
193 };
194 format!("lb.drpc.org/ogrpc?network={network}&dkey={key}")
195 };
196
197 let url = if is_ws { format!("wss://{domain}") } else { format!("https://{domain}") };
198
199 eprintln!("--- next_url(is_ws={is_ws}, chain={chain:?}) = {url} ---");
200 url
201}
202
203#[cfg(test)]
204#[expect(clippy::disallowed_macros)]
205mod tests {
206 use super::*;
207 use alloy_primitives::address;
208 use foundry_block_explorers::EtherscanApiVersion;
209 use foundry_config::Chain;
210
211 #[tokio::test]
212 #[ignore = "run manually"]
213 async fn test_etherscan_keys() {
214 let address = address!("0xdAC17F958D2ee523a2206206994597C13D831ec7");
215 let mut first_abi = None;
216 let mut failed = Vec::new();
217 for (i, key) in ETHERSCAN_KEYS.iter().enumerate() {
218 println!("trying key {i} ({key})");
219
220 let client = foundry_block_explorers::Client::builder()
221 .chain(Chain::mainnet())
222 .unwrap()
223 .with_api_key(key)
224 .build()
225 .unwrap();
226
227 let mut fail = |e: &str| {
228 eprintln!("key {i} ({key}) failed: {e}");
229 failed.push(key.as_str());
230 };
231
232 let abi = match client.contract_abi(address).await {
233 Ok(abi) => abi,
234 Err(e) => {
235 fail(&e.to_string());
236 continue;
237 }
238 };
239
240 if let Some(first_abi) = &first_abi {
241 if abi != *first_abi {
242 fail("abi mismatch");
243 }
244 } else {
245 first_abi = Some(abi);
246 }
247 }
248 if !failed.is_empty() {
249 panic!("failed keys: {failed:#?}");
250 }
251 }
252
253 #[tokio::test]
254 #[ignore = "run manually"]
255 async fn test_etherscan_keys_compatibility() {
256 let address = address!("0x111111125421cA6dc452d289314280a0f8842A65");
257 let etherscan_key = "JQNGFHINKS1W7Y5FRXU4SPBYF43J3NYK46";
258 let client = foundry_block_explorers::Client::builder()
259 .with_api_key(etherscan_key)
260 .chain(Chain::optimism_mainnet())
261 .unwrap()
262 .build()
263 .unwrap();
264 if client.contract_abi(address).await.is_ok() {
265 panic!("v1 Optimism key should not work with v2 version")
266 }
267
268 let client = foundry_block_explorers::Client::builder()
269 .with_api_key(etherscan_key)
270 .with_api_version(EtherscanApiVersion::V1)
271 .chain(Chain::optimism_mainnet())
272 .unwrap()
273 .build()
274 .unwrap();
275 match client.contract_abi(address).await {
276 Ok(_) => {}
277 Err(_) => panic!("v1 Optimism key should work with v1 version"),
278 };
279 }
280}