Skip to main content

foundry_test_utils/
rpc.rs

1//! RPC API keys utilities.
2
3use foundry_config::{
4    NamedChain::{
5        self, Arbitrum, Base, BinanceSmartChainTestnet, Celo, Mainnet, Optimism, Polygon, Sepolia,
6    },
7    RpcEndpointUrl, RpcEndpoints,
8};
9use rand::seq::SliceRandom;
10use std::{
11    env,
12    sync::{
13        LazyLock,
14        atomic::{AtomicUsize, Ordering},
15    },
16};
17
18macro_rules! shuffled_list {
19    ($name:ident, $e:expr $(,)?) => {
20        static $name: LazyLock<ShuffledList<&'static str>> =
21            LazyLock::new(|| ShuffledList::new($e));
22    };
23}
24
25struct ShuffledList<T> {
26    list: Vec<T>,
27    index: AtomicUsize,
28}
29
30impl<T> ShuffledList<T> {
31    fn new(mut list: Vec<T>) -> Self {
32        assert!(!list.is_empty());
33        list.shuffle(&mut rand::rng());
34        Self { list, index: AtomicUsize::new(0) }
35    }
36
37    fn next(&self) -> &T {
38        let index = self.index.fetch_add(1, Ordering::Relaxed);
39        &self.list[index % self.list.len()]
40    }
41}
42
43shuffled_list!(
44    HTTP_ARCHIVE_DOMAINS,
45    vec![
46        //
47        "ethereum.reth.rs/rpc",
48    ],
49);
50shuffled_list!(
51    HTTP_DOMAINS,
52    vec![
53        //
54        "ethereum.reth.rs/rpc",
55    ],
56);
57shuffled_list!(
58    WS_ARCHIVE_DOMAINS,
59    vec![
60        //
61        "ethereum.reth.rs/ws",
62    ],
63);
64shuffled_list!(
65    WS_DOMAINS,
66    vec![
67        //
68        "ethereum.reth.rs/ws",
69    ],
70);
71
72// List of general purpose DRPC keys to rotate through
73shuffled_list!(
74    DRPC_KEYS,
75    vec![
76        "Agc9NK9-6UzYh-vQDDM80Tv0A5UnBkUR8I3qssvAG40d",
77        "AjUPUPonSEInt2CZ_7A-ai3hMyxxBlsR8I4EssvAG40d",
78    ],
79);
80
81// List of etherscan keys.
82shuffled_list!(
83    ETHERSCAN_KEYS,
84    vec![
85        "MCAUM7WPE9XP5UQMZPCKIBUJHPM1C24FP6",
86        "JW6RWCG2C5QF8TANH4KC7AYIF1CX7RB5D1",
87        "ZSMDY6BI2H55MBE3G9CUUQT4XYUDBB6ZSK",
88        "4FYHTY429IXYMJNS4TITKDMUKW5QRYDX61",
89        "QYKNT5RHASZ7PGQE68FNQWH99IXVTVVD2I",
90        "VXMQ117UN58Y4RHWUB8K1UGCEA7UQEWK55",
91        "C7I2G4JTA5EPYS42Z8IZFEIMQNI5GXIJEV",
92        "A15KZUMZXXCK1P25Y1VP1WGIVBBHIZDS74",
93        "3IA6ASNQXN8WKN7PNFX7T72S9YG56X9FPG",
94    ],
95);
96
97/// the RPC endpoints used during tests
98pub fn rpc_endpoints() -> RpcEndpoints {
99    RpcEndpoints::new([
100        ("mainnet", RpcEndpointUrl::Url(next_http_archive_rpc_url())),
101        ("mainnet2", RpcEndpointUrl::Url(next_http_archive_rpc_url())),
102        ("sepolia", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Sepolia))),
103        ("optimism", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Optimism))),
104        ("arbitrum", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Arbitrum))),
105        ("polygon", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Polygon))),
106        ("bsc", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::BinanceSmartChain))),
107        ("avaxTestnet", RpcEndpointUrl::Url("https://api.avax-test.network/ext/bc/C/rpc".into())),
108        ("moonbeam", RpcEndpointUrl::Url("https://moonbeam-rpc.publicnode.com".into())),
109        ("polkadotTestnet", RpcEndpointUrl::Url("https://eth-rpc-testnet.polkadot.io".into())),
110        ("kusama", RpcEndpointUrl::Url("https://eth-rpc-kusama.polkadot.io".into())),
111        ("polkadot", RpcEndpointUrl::Url("https://eth-rpc.polkadot.io".into())),
112        ("rpcEnvAlias", RpcEndpointUrl::Env("${RPC_ENV_ALIAS}".into())),
113    ])
114}
115
116/// Returns the next _mainnet_ rpc URL in inline
117///
118/// This will rotate all available rpc endpoints
119pub fn next_http_rpc_endpoint() -> String {
120    next_rpc_endpoint(NamedChain::Mainnet)
121}
122
123/// Returns the next _mainnet_ rpc URL in inline
124///
125/// This will rotate all available rpc endpoints
126pub fn next_ws_rpc_endpoint() -> String {
127    next_ws_endpoint(NamedChain::Mainnet)
128}
129
130/// Returns the next HTTP RPC URL.
131pub fn next_rpc_endpoint(chain: NamedChain) -> String {
132    next_url(false, chain)
133}
134
135/// Returns the next WS RPC URL.
136pub fn next_ws_endpoint(chain: NamedChain) -> String {
137    next_url(true, chain)
138}
139
140/// Returns a websocket URL that has access to archive state
141pub fn next_http_archive_rpc_url() -> String {
142    next_archive_url(false)
143}
144
145/// Returns an HTTP URL that has access to archive state
146pub fn next_ws_archive_rpc_url() -> String {
147    next_archive_url(true)
148}
149
150/// Returns a URL that has access to archive state.
151fn next_archive_url(is_ws: bool) -> String {
152    let domain = if is_ws { &WS_ARCHIVE_DOMAINS } else { &HTTP_ARCHIVE_DOMAINS }.next();
153    let url = if is_ws { format!("wss://{domain}") } else { format!("https://{domain}") };
154    test_debug!("next_archive_url(is_ws={is_ws}) = {}", debug_url(&url));
155    url
156}
157
158/// Returns the next etherscan api key.
159pub fn next_etherscan_api_key() -> String {
160    let mut key = env::var("ETHERSCAN_KEY").unwrap_or_default();
161    if key.is_empty() {
162        key = ETHERSCAN_KEYS.next().to_string();
163    }
164    test_debug!("next_etherscan_api_key() = {}...", &key[..6]);
165    key
166}
167
168fn next_url(is_ws: bool, chain: NamedChain) -> String {
169    let url = next_url_inner(is_ws, chain);
170    test_debug!("next_url(is_ws={is_ws}, chain={chain:?}) = {}", debug_url(&url));
171    url
172}
173
174fn next_url_inner(is_ws: bool, chain: NamedChain) -> String {
175    if matches!(chain, Base) {
176        return "https://mainnet.base.org".to_string();
177    }
178
179    if matches!(chain, Optimism) {
180        return "https://mainnet.optimism.io".to_string();
181    }
182
183    if matches!(chain, BinanceSmartChainTestnet) {
184        return "https://bsc-testnet-rpc.publicnode.com".to_string();
185    }
186
187    if matches!(chain, Celo) {
188        return "https://celo.drpc.org".to_string();
189    }
190
191    if matches!(chain, Sepolia) {
192        let rpc_url = env::var("ETH_SEPOLIA_RPC").unwrap_or_default();
193        if !rpc_url.is_empty() {
194            return rpc_url;
195        }
196    }
197
198    if matches!(chain, Arbitrum) {
199        let rpc_url = env::var("ARBITRUM_RPC").unwrap_or_default();
200        if !rpc_url.is_empty() {
201            return rpc_url;
202        }
203    }
204
205    let reth_works = true;
206    let domain = if reth_works && matches!(chain, Mainnet) {
207        *(if is_ws { &WS_DOMAINS } else { &HTTP_DOMAINS }).next()
208    } else {
209        // DRPC for other networks used in tests.
210        let key = DRPC_KEYS.next();
211        let network = match chain {
212            Mainnet => "ethereum",
213            Polygon => "polygon",
214            Arbitrum => "arbitrum",
215            Sepolia => "sepolia",
216            _ => "",
217        };
218        &format!("lb.drpc.org/ogrpc?network={network}&dkey={key}")
219    };
220
221    if is_ws { format!("wss://{domain}") } else { format!("https://{domain}") }
222}
223
224/// Basic redaction for debugging RPC URLs.
225fn debug_url(url: &str) -> impl std::fmt::Display + '_ {
226    let url = reqwest::Url::parse(url).unwrap();
227    format!(
228        "{scheme}://{host}{path}",
229        scheme = url.scheme(),
230        host = url.host_str().unwrap(),
231        path = url.path().get(..8).unwrap_or(url.path()),
232    )
233}
234
235#[cfg(test)]
236#[expect(clippy::disallowed_macros)]
237mod tests {
238    use super::*;
239    use alloy_primitives::address;
240    use foundry_config::Chain;
241
242    #[tokio::test]
243    #[ignore = "run manually"]
244    async fn test_etherscan_keys() {
245        let address = address!("0xdAC17F958D2ee523a2206206994597C13D831ec7");
246        let mut first_abi = None;
247        let mut failed = Vec::new();
248        for (i, &key) in ETHERSCAN_KEYS.list.iter().enumerate() {
249            println!("trying key {i} ({key})");
250
251            let client = foundry_block_explorers::Client::builder()
252                .chain(Chain::mainnet())
253                .unwrap()
254                .with_api_key(key)
255                .build()
256                .unwrap();
257
258            let mut fail = |e: &str| {
259                eprintln!("key {i} ({key}) failed: {e}");
260                failed.push(key);
261            };
262
263            let abi = match client.contract_abi(address).await {
264                Ok(abi) => abi,
265                Err(e) => {
266                    fail(&e.to_string());
267                    continue;
268                }
269            };
270
271            if let Some(first_abi) = &first_abi {
272                if abi != *first_abi {
273                    fail("abi mismatch");
274                }
275            } else {
276                first_abi = Some(abi);
277            }
278        }
279        if !failed.is_empty() {
280            panic!("failed keys: {failed:#?}");
281        }
282    }
283}