Skip to main content

foundry_test_utils/
rpc.rs

1//! RPC API keys utilities.
2
3use 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
18// List of public archive reth nodes to use
19static RETH_ARCHIVE_HOSTS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
20    shuffled(vec![
21        //
22        "reth-ethereum.ithaca.xyz",
23    ])
24});
25
26// List of public reth nodes to use (archive and non archive)
27static RETH_HOSTS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
28    shuffled(vec![
29        //
30        "reth-ethereum.ithaca.xyz",
31        "reth-ethereum-full.ithaca.xyz",
32    ])
33});
34
35// List of general purpose DRPC keys to rotate through
36static DRPC_KEYS: LazyLock<Vec<String>> = LazyLock::new(|| {
37    let mut keys = vec!["AgasqIYODEW_j_J0F91L8oETmhtHCXkR8JAVssvAG40d".to_owned()];
38    // Fetch secret from GitHub Actions environment variable
39    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
49/// Returns the fallback hardcoded Etherscan API keys.
50fn 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
65// List of etherscan keys.
66static ETHERSCAN_KEYS: LazyLock<Vec<String>> = LazyLock::new(|| {
67    // Fetch from GitHub Actions environment variable (comma-separated) or use fallback
68    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
84/// Returns the next index to use.
85fn next_idx() -> usize {
86    static NEXT_INDEX: AtomicUsize = AtomicUsize::new(0);
87    NEXT_INDEX.fetch_add(1, Ordering::SeqCst)
88}
89
90/// Returns the next item in the list to use.
91fn next<T>(list: &[T]) -> &T {
92    &list[next_idx() % list.len()]
93}
94
95/// Returns the next _mainnet_ rpc URL in inline
96///
97/// This will rotate all available rpc endpoints
98pub fn next_http_rpc_endpoint() -> String {
99    next_rpc_endpoint(NamedChain::Mainnet)
100}
101
102/// Returns the next _mainnet_ rpc URL in inline
103///
104/// This will rotate all available rpc endpoints
105pub fn next_ws_rpc_endpoint() -> String {
106    next_ws_endpoint(NamedChain::Mainnet)
107}
108
109/// Returns the next HTTP RPC URL.
110pub fn next_rpc_endpoint(chain: NamedChain) -> String {
111    next_url(false, chain)
112}
113
114/// Returns the next WS RPC URL.
115pub fn next_ws_endpoint(chain: NamedChain) -> String {
116    next_url(true, chain)
117}
118
119/// Returns a websocket URL that has access to archive state
120pub fn next_http_archive_rpc_url() -> String {
121    next_archive_url(false)
122}
123
124/// Returns an HTTP URL that has access to archive state
125pub fn next_ws_archive_rpc_url() -> String {
126    next_archive_url(true)
127}
128
129/// Returns a URL that has access to archive state.
130fn 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
158/// Returns the next etherscan api key.
159pub fn next_etherscan_api_key() -> String {
160    let key = next(&ETHERSCAN_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        // For Mainnet pick one of Reth nodes.
180        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        // DRPC for other networks used in tests.
185        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}