Skip to main content

foundry_evm_traces/identifier/
external.rs

1use super::{IdentifiedAddress, TraceIdentifier};
2use crate::debug::ContractSources;
3use alloy_json_abi::JsonAbi;
4use alloy_primitives::{
5    Address,
6    map::{AddressMap, AddressSet, Entry, HashMap, HashSet},
7};
8use eyre::WrapErr;
9use foundry_block_explorers::{contract::Metadata, errors::EtherscanError};
10use foundry_common::compile::etherscan_project;
11use foundry_config::{Chain, Config, EtherscanConfigs};
12use futures::{
13    future::join_all,
14    stream::{FuturesUnordered, Stream, StreamExt},
15    task::{Context, Poll},
16};
17use revm_inspectors::tracing::types::CallTraceNode;
18use serde::Deserialize;
19use std::{
20    borrow::Cow,
21    pin::Pin,
22    sync::{
23        Arc,
24        atomic::{AtomicBool, Ordering},
25    },
26};
27use tokio::time::{Duration, Interval};
28
29/// A trace identifier that tries to identify addresses using Etherscan.
30pub struct ExternalIdentifier {
31    fetchers: Vec<Arc<dyn ExternalFetcherT>>,
32    /// Cached contracts.
33    contracts: HashMap<Address, (FetcherKind, Option<Metadata>)>,
34    /// Remaining time external identification may block trace rendering.
35    remaining_budget: Duration,
36}
37
38/// The [`Config`] settings an [`ExternalIdentifier`] is built from, detached from the config
39/// itself.
40///
41/// Lets a consumer that keeps only a snapshot of the config — the cheatcode config — build an
42/// identifier for a chain it doesn't learn until runtime, when a test selects a fork.
43#[derive(Clone, Debug, Default)]
44pub struct ExternalIdentifierConfig {
45    /// Whether network access is disabled altogether.
46    offline: bool,
47    /// How long identification may block, in seconds. Zero disables it.
48    timeout: u64,
49    /// Whether to skip system proxy lookups when building the explorer client.
50    no_proxy: bool,
51    /// The `[etherscan]` table, and the settings that pick an entry out of it.
52    etherscan: EtherscanConfigs,
53    etherscan_alias: Option<String>,
54    etherscan_api_key: Option<String>,
55    /// The configured chain, used when the caller doesn't name one.
56    chain: Option<Chain>,
57}
58
59impl ExternalIdentifierConfig {
60    /// Takes the settings an [`ExternalIdentifier`] needs out of `config`.
61    pub fn new(config: &Config) -> Self {
62        Self {
63            offline: config.offline,
64            timeout: config.tracing.external_identification_timeout,
65            no_proxy: config.eth_rpc_no_proxy,
66            etherscan: config.etherscan.clone(),
67            etherscan_alias: config.etherscan_alias().map(str::to_string),
68            etherscan_api_key: config.etherscan_api_key.clone(),
69            chain: config.chain,
70        }
71    }
72
73    /// Builds an identifier that looks contracts up on `chain`.
74    ///
75    /// Returns `None` when there is nothing to look them up with: identification is off, or
76    /// neither Sourcify nor a block explorer is usable.
77    pub fn identifier(&self, chain: Option<Chain>) -> Option<ExternalIdentifier> {
78        self.identifier_with(
79            chain,
80            self.etherscan_alias.as_deref(),
81            self.etherscan_api_key.as_deref(),
82            true,
83        )
84    }
85
86    /// Builds an identifier for compiling storage layouts on exactly `chain`.
87    ///
88    /// Storage layouts are cached by chain, so an alias for another chain must not override the
89    /// chain that executed the recorded storage access. Sourcify is omitted because its compact
90    /// response does not contain the verified sources required for compilation.
91    pub fn storage_identifier(&self, chain: Chain) -> Option<ExternalIdentifier> {
92        // `etherscan_api_key` also accepts a table alias. Do not reuse an alias name as the
93        // literal API key for a different runtime chain.
94        let api_key =
95            self.etherscan_api_key.as_deref().filter(|key| !self.etherscan.contains_key(*key));
96        self.identifier_with(Some(chain), None, api_key, false)
97    }
98
99    fn identifier_with(
100        &self,
101        mut chain: Option<Chain>,
102        etherscan_alias: Option<&str>,
103        etherscan_api_key: Option<&str>,
104        sourcify: bool,
105    ) -> Option<ExternalIdentifier> {
106        if self.offline || self.timeout == 0 {
107            return None;
108        }
109
110        let resolved =
111            self.etherscan.resolve_for(etherscan_alias, etherscan_api_key, chain.or(self.chain));
112        let etherscan = match resolved {
113            Ok(Some(config)) => {
114                chain = config.chain;
115                Some(config)
116            }
117            Ok(None) => {
118                warn!(target: "evm::traces::external", "etherscan config not found");
119                None
120            }
121            Err(err) => {
122                warn!(target: "evm::traces::external", ?err, "failed to get etherscan config");
123                None
124            }
125        };
126
127        let mut fetchers = Vec::<Arc<dyn ExternalFetcherT>>::new();
128        if sourcify && let Some(chain) = chain {
129            debug!(target: "evm::traces::external", ?chain, "using sourcify identifier");
130            fetchers.push(Arc::new(SourcifyFetcher::new(chain)));
131        }
132        if let Some(config) = etherscan {
133            debug!(target: "evm::traces::external", chain=?config.chain, url=?config.api_url, "using etherscan identifier");
134            match config.into_client_with_no_proxy(self.no_proxy) {
135                Ok(client) => {
136                    fetchers.push(Arc::new(EtherscanFetcher::new(client)));
137                }
138                Err(err) => {
139                    warn!(target: "evm::traces::external", ?err, "failed to create etherscan client");
140                }
141            }
142        }
143        if fetchers.is_empty() {
144            debug!(target: "evm::traces::external", "no fetchers enabled");
145            return None;
146        }
147
148        Some(ExternalIdentifier {
149            fetchers,
150            contracts: Default::default(),
151            remaining_budget: Duration::from_secs(self.timeout),
152        })
153    }
154
155    /// Maximum time a storage-layout lookup may block.
156    pub const fn storage_timeout(&self) -> Duration {
157        Duration::from_secs(self.timeout)
158    }
159}
160
161impl ExternalIdentifier {
162    /// Creates a new external identifier with the given client.
163    pub fn new(config: &Config, chain: Option<Chain>) -> eyre::Result<Option<Self>> {
164        Ok(ExternalIdentifierConfig::new(config).identifier(chain))
165    }
166
167    /// Goes over the list of contracts we have pulled from the traces, clones their source from
168    /// Etherscan and compiles them locally, for usage in the debugger.
169    pub async fn get_compiled_contracts(&self) -> eyre::Result<ContractSources> {
170        // Collect contract info upfront so we can reference it in error messages
171        let contracts_info: Vec<_> = self
172            .contracts
173            .iter()
174            // filter out vyper files and contracts without metadata
175            .filter_map(|(addr, (_, metadata))| {
176                if let Some(metadata) = metadata.as_ref()
177                    && !metadata.is_vyper()
178                {
179                    Some((*addr, metadata))
180                } else {
181                    None
182                }
183            })
184            .collect();
185
186        let outputs_fut = contracts_info
187            .iter()
188            .map(|(addr, metadata)| async move {
189                sh_println!("Compiling: {} {addr}", metadata.contract_name)?;
190                let root = tempfile::tempdir()?;
191                let root_path = root.path();
192                let project = etherscan_project(metadata, root_path)?;
193                let output = project.compile()?;
194                if output.has_compiler_errors() {
195                    eyre::bail!("{output}");
196                }
197
198                Ok((project, output, root))
199            })
200            .collect::<Vec<_>>();
201
202        // poll all the futures concurrently
203        let outputs = join_all(outputs_fut).await;
204
205        let mut sources: ContractSources = Default::default();
206
207        // construct the map
208        for (idx, res) in outputs.into_iter().enumerate() {
209            let (addr, metadata) = &contracts_info[idx];
210            let name = &metadata.contract_name;
211            let (project, output, _) =
212                res.wrap_err_with(|| format!("Failed to compile contract {name} at {addr}"))?;
213            sources
214                .insert(&output, project.root(), None)
215                .wrap_err_with(|| format!("Failed to insert contract {name} at {addr}"))?;
216        }
217
218        Ok(sources)
219    }
220
221    fn identify_from_metadata(
222        &self,
223        address: Address,
224        metadata: &Metadata,
225    ) -> IdentifiedAddress<'static> {
226        let label = metadata.contract_name.clone();
227        let abi = metadata.abi().ok().map(Cow::Owned);
228        IdentifiedAddress {
229            address,
230            label: Some(label.clone()),
231            contract: Some(label),
232            abi,
233            constructor_args_offset: None,
234            artifact_id: None,
235        }
236    }
237
238    fn cache_fetched(&mut self, address: Address, value: (FetcherKind, Option<Metadata>)) {
239        match self.contracts.entry(address) {
240            Entry::Occupied(mut occupied_entry) => {
241                let old = occupied_entry.get();
242                // Only override when the new result is strictly better:
243                // - new has metadata and old doesn't, OR
244                // - both have metadata but new is from Etherscan and old is not.
245                // Never downgrade a successful lookup to None.
246                let should_replace = match (&old.1, &value.1) {
247                    (None, Some(_)) => true,
248                    (Some(_), None) => false,
249                    _ => {
250                        matches!(value.0, FetcherKind::Etherscan)
251                            && !matches!(old.0, FetcherKind::Etherscan)
252                    }
253                };
254                if should_replace {
255                    occupied_entry.insert(value);
256                }
257            }
258            Entry::Vacant(vacant_entry) => {
259                vacant_entry.insert(value);
260            }
261        }
262    }
263
264    async fn fetch_addresses_async(&mut self, addresses: &[Address]) {
265        self.fetch_addresses_with_timeout(addresses, self.remaining_budget).await;
266    }
267
268    async fn fetch_addresses_with_timeout(&mut self, addresses: &[Address], timeout: Duration) {
269        let timeout = timeout.min(self.remaining_budget);
270        if addresses.is_empty() || timeout.is_zero() {
271            return;
272        }
273
274        let fetchers = self
275            .fetchers
276            .clone()
277            .into_iter()
278            .map(|fetcher| ExternalFetcher::new(fetcher, addresses));
279        let started = tokio::time::Instant::now();
280        let timed_out = tokio::time::timeout(timeout, async {
281            let mut fetched = futures::stream::select_all(fetchers);
282            while let Some((address, value)) = fetched.next().await {
283                self.cache_fetched(address, value);
284            }
285        })
286        .await
287        .is_err();
288        self.remaining_budget = self.remaining_budget.saturating_sub(started.elapsed());
289        if timed_out && self.remaining_budget.is_zero() {
290            warn!(target: "evm::traces::external", "external identification timed out; disabling it for the remainder of this session");
291        }
292    }
293
294    /// Fetches all verified ABIs and whether each proxy chain was fully resolved.
295    pub async fn get_abis(
296        &mut self,
297        addresses: &[Address],
298    ) -> Vec<(Address, eyre::Result<(Vec<JsonAbi>, bool)>)> {
299        const MAX_PROXY_DEPTH: usize = 16;
300
301        struct Chain {
302            current: Option<Address>,
303            visited: HashSet<Address>,
304            abis: Vec<JsonAbi>,
305            complete: bool,
306        }
307
308        let mut chains = addresses
309            .iter()
310            .map(|&address| Chain {
311                current: Some(address),
312                visited: HashSet::default(),
313                abis: Vec::new(),
314                complete: true,
315            })
316            .collect::<Vec<_>>();
317
318        for _ in 0..MAX_PROXY_DEPTH {
319            let to_fetch = chains
320                .iter()
321                .filter_map(|chain| chain.current)
322                .filter(|address| !self.contracts.contains_key(address))
323                .collect::<HashSet<_>>()
324                .into_iter()
325                .collect::<Vec<_>>();
326            self.fetch_addresses_async(&to_fetch).await;
327
328            let mut has_next = false;
329            for chain in &mut chains {
330                let Some(current) = chain.current else { continue };
331                if !chain.visited.insert(current) {
332                    chain.current = None;
333                    chain.complete = false;
334                    continue;
335                }
336                let Some((_, Some(metadata))) = self.contracts.get(&current) else {
337                    chain.current = None;
338                    chain.complete = false;
339                    continue;
340                };
341                if let Ok(abi) = metadata.abi() {
342                    chain.abis.push(abi);
343                } else {
344                    chain.complete = false;
345                }
346                chain.current = (metadata.proxy != 0).then_some(metadata.implementation).flatten();
347                if metadata.proxy != 0 && chain.current.is_none() {
348                    chain.complete = false;
349                }
350                has_next |= chain.current.is_some();
351            }
352            if !has_next {
353                break;
354            }
355        }
356
357        chains
358            .into_iter()
359            .zip(addresses.iter().copied())
360            .map(|(mut chain, address)| {
361                chain.complete &= chain.current.is_none();
362                let result = if chain.abis.is_empty() {
363                    Err(eyre::eyre!("external ABI lookup failed"))
364                } else {
365                    Ok((chain.abis.into_iter().rev().collect(), chain.complete))
366                };
367                (address, result)
368            })
369            .collect()
370    }
371
372    /// Fetches metadata for the exact addresses supplied, without following explorer proxy hints.
373    ///
374    /// Storage decoding uses the implementation address recorded by the EVM, which is authoritative
375    /// for historical forks and proxies that upgrade over time.
376    pub async fn get_metadata(
377        &mut self,
378        addresses: &[Address],
379        timeout: Duration,
380    ) -> AddressMap<Option<Metadata>> {
381        let to_fetch = addresses
382            .iter()
383            .copied()
384            .filter(|address| !self.contracts.contains_key(address))
385            .collect::<Vec<_>>();
386        self.fetch_addresses_with_timeout(&to_fetch, timeout).await;
387        addresses
388            .iter()
389            .filter_map(|address| {
390                self.contracts.get(address).map(|(_, metadata)| (*address, metadata.clone()))
391            })
392            .collect()
393    }
394}
395
396impl TraceIdentifier for ExternalIdentifier {
397    fn identify_addresses(&mut self, nodes: &[&CallTraceNode]) -> Vec<IdentifiedAddress<'_>> {
398        if nodes.is_empty() {
399            return Vec::new();
400        }
401
402        trace!(target: "evm::traces::external", "identify {} addresses", nodes.len());
403
404        let mut identities = Vec::new();
405        let mut to_fetch = AddressSet::default();
406
407        // Check cache first.
408        for &node in nodes {
409            let address = node.trace.address;
410            if let Some((_, metadata)) = self.contracts.get(&address) {
411                if let Some(metadata) = metadata {
412                    identities.push(self.identify_from_metadata(address, metadata));
413                } else {
414                    // Do nothing. We know that this contract was not verified.
415                }
416            } else {
417                to_fetch.insert(address);
418            }
419        }
420
421        if to_fetch.is_empty() {
422            return identities;
423        }
424        if self.remaining_budget.is_zero() {
425            return identities;
426        }
427        trace!(target: "evm::traces::external", "fetching {} addresses", to_fetch.len());
428
429        let to_fetch = to_fetch.into_iter().collect::<Vec<_>>();
430        foundry_common::block_on(self.fetch_addresses_async(&to_fetch));
431
432        for address in to_fetch {
433            if let Some((_, Some(metadata))) = self.contracts.get(&address) {
434                identities.push(self.identify_from_metadata(address, metadata));
435            }
436        }
437        trace!(target: "evm::traces::external", "identified {} addresses", identities.len());
438        identities
439    }
440}
441
442type FetchFuture =
443    Pin<Box<dyn Future<Output = (Address, Result<Option<Metadata>, EtherscanError>)>>>;
444
445/// Maximum number of times a single address is retried through a transient Cloudflare
446/// block before we give up on it. Bounded so a persistent block can't loop forever.
447const MAX_CLOUDFLARE_RETRIES: u32 = 5;
448
449fn backoff_interval(period: Duration) -> Interval {
450    tokio::time::interval_at(tokio::time::Instant::now() + period, period)
451}
452
453/// A rate limit aware fetcher.
454///
455/// Fetches information about multiple addresses concurrently, while respecting rate limits.
456struct ExternalFetcher {
457    /// The fetcher
458    fetcher: Arc<dyn ExternalFetcherT>,
459    /// The time we wait if we hit the rate limit
460    timeout: Duration,
461    /// The interval we are currently waiting for before making a new request
462    backoff: Option<Interval>,
463    /// The maximum amount of requests to send concurrently
464    concurrency: usize,
465    /// The addresses we have yet to make requests for
466    queue: Vec<Address>,
467    /// The in progress requests
468    in_progress: FuturesUnordered<FetchFuture>,
469    /// Per-address retry counter for transient Cloudflare blocks.
470    attempts: HashMap<Address, u32>,
471}
472
473impl ExternalFetcher {
474    fn new(fetcher: Arc<dyn ExternalFetcherT>, to_fetch: &[Address]) -> Self {
475        Self {
476            timeout: fetcher.timeout(),
477            backoff: None,
478            concurrency: fetcher.concurrency(),
479            fetcher,
480            queue: to_fetch.to_vec(),
481            in_progress: FuturesUnordered::new(),
482            attempts: HashMap::default(),
483        }
484    }
485
486    fn queue_next_reqs(&mut self) {
487        while self.in_progress.len() < self.concurrency {
488            let Some(addr) = self.queue.pop() else { break };
489            let fetcher = Arc::clone(&self.fetcher);
490            self.in_progress.push(Box::pin(async move {
491                trace!(target: "evm::traces::external", ?addr, "fetching info");
492                let res = fetcher.fetch(addr).await;
493                (addr, res)
494            }));
495        }
496    }
497}
498
499impl Stream for ExternalFetcher {
500    type Item = (Address, (FetcherKind, Option<Metadata>));
501
502    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
503        let pin = self.get_mut();
504
505        let _guard =
506            info_span!("evm::traces::external", kind=?pin.fetcher.kind(), "ExternalFetcher")
507                .entered();
508
509        if pin.fetcher.invalid_api_key().load(Ordering::Relaxed) {
510            return Poll::Ready(None);
511        }
512
513        loop {
514            if let Some(mut backoff) = pin.backoff.take()
515                && backoff.poll_tick(cx).is_pending()
516            {
517                pin.backoff = Some(backoff);
518                return Poll::Pending;
519            }
520
521            pin.queue_next_reqs();
522
523            let mut made_progress_this_iter = false;
524            match pin.in_progress.poll_next_unpin(cx) {
525                Poll::Pending => {}
526                Poll::Ready(None) => return Poll::Ready(None),
527                Poll::Ready(Some((addr, res))) => {
528                    made_progress_this_iter = true;
529                    match res {
530                        Ok(metadata) => {
531                            return Poll::Ready(Some((addr, (pin.fetcher.kind(), metadata))));
532                        }
533                        Err(EtherscanError::ContractCodeNotVerified(_)) => {
534                            return Poll::Ready(Some((addr, (pin.fetcher.kind(), None))));
535                        }
536                        Err(EtherscanError::RateLimitExceeded) => {
537                            warn!(target: "evm::traces::external", "rate limit exceeded on attempt");
538                            pin.backoff = Some(backoff_interval(pin.timeout));
539                            pin.queue.push(addr);
540                        }
541                        Err(EtherscanError::InvalidApiKey) => {
542                            warn!(target: "evm::traces::external", "invalid api key");
543                            // mark key as invalid
544                            pin.fetcher.invalid_api_key().store(true, Ordering::Relaxed);
545                            return Poll::Ready(None);
546                        }
547                        Err(EtherscanError::BlockedByCloudflare) => {
548                            // A Cloudflare block is transient rate limiting (often triggered
549                            // by request bursts), not a permanent failure like an invalid key.
550                            // Back off and retry the address a bounded number of times instead
551                            // of aborting the whole stream, which would abandon every still-
552                            // queued address and leave traces only partially decoded (#9880).
553                            let attempts = {
554                                let entry = pin.attempts.entry(addr).or_default();
555                                *entry += 1;
556                                *entry
557                            };
558                            if attempts <= MAX_CLOUDFLARE_RETRIES {
559                                warn!(target: "evm::traces::external", attempts, "blocked by cloudflare, backing off");
560                                pin.backoff = Some(backoff_interval(pin.timeout));
561                                pin.queue.push(addr);
562                            } else {
563                                warn!(target: "evm::traces::external", "blocked by cloudflare, giving up on address");
564                                // No conclusion: do not turn a transient outage into a cached
565                                // "unverified" result.
566                            }
567                        }
568                        Err(err) => {
569                            warn!(target: "evm::traces::external", ?err, "could not get info");
570                            // Only ContractCodeNotVerified is a conclusive negative result.
571                        }
572                    }
573                }
574            }
575
576            if !made_progress_this_iter {
577                return Poll::Pending;
578            }
579        }
580    }
581}
582
583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
584enum FetcherKind {
585    Etherscan,
586    Sourcify,
587}
588
589#[async_trait::async_trait]
590trait ExternalFetcherT: Send + Sync {
591    fn kind(&self) -> FetcherKind;
592    fn timeout(&self) -> Duration;
593    fn concurrency(&self) -> usize;
594    fn invalid_api_key(&self) -> &AtomicBool;
595    async fn fetch(&self, address: Address) -> Result<Option<Metadata>, EtherscanError>;
596}
597
598struct EtherscanFetcher {
599    client: foundry_block_explorers::Client,
600    invalid_api_key: AtomicBool,
601}
602
603impl EtherscanFetcher {
604    const fn new(client: foundry_block_explorers::Client) -> Self {
605        Self { client, invalid_api_key: AtomicBool::new(false) }
606    }
607}
608
609#[async_trait::async_trait]
610impl ExternalFetcherT for EtherscanFetcher {
611    fn kind(&self) -> FetcherKind {
612        FetcherKind::Etherscan
613    }
614
615    fn timeout(&self) -> Duration {
616        Duration::from_secs(1)
617    }
618
619    fn concurrency(&self) -> usize {
620        5
621    }
622
623    fn invalid_api_key(&self) -> &AtomicBool {
624        &self.invalid_api_key
625    }
626
627    async fn fetch(&self, address: Address) -> Result<Option<Metadata>, EtherscanError> {
628        self.client.contract_source_code(address).await.map(|mut metadata| metadata.items.pop())
629    }
630}
631
632struct SourcifyFetcher {
633    client: reqwest::Client,
634    url: String,
635    invalid_api_key: AtomicBool,
636}
637
638impl SourcifyFetcher {
639    fn new(chain: Chain) -> Self {
640        Self {
641            client: reqwest::Client::builder()
642                .user_agent(foundry_common::DEFAULT_USER_AGENT)
643                .build()
644                .expect("Client::builder() with static config cannot fail"),
645            url: format!("https://sourcify.dev/server/v2/contract/{}", chain.id()),
646            invalid_api_key: AtomicBool::new(false),
647        }
648    }
649}
650
651#[async_trait::async_trait]
652impl ExternalFetcherT for SourcifyFetcher {
653    fn kind(&self) -> FetcherKind {
654        FetcherKind::Sourcify
655    }
656
657    fn timeout(&self) -> Duration {
658        Duration::from_secs(1)
659    }
660
661    fn concurrency(&self) -> usize {
662        5
663    }
664
665    fn invalid_api_key(&self) -> &AtomicBool {
666        &self.invalid_api_key
667    }
668
669    async fn fetch(&self, address: Address) -> Result<Option<Metadata>, EtherscanError> {
670        let url = format!("{url}/{address}?fields=abi,compilation", url = self.url);
671        let response = self
672            .client
673            .get(url)
674            .send()
675            .await
676            .map_err(|e| EtherscanError::Unknown(e.to_string()))?;
677        let code = response.status();
678        match code.as_u16() {
679            // Not verified.
680            404 => return Err(EtherscanError::ContractCodeNotVerified(address)),
681            // Too many requests.
682            429 => return Err(EtherscanError::RateLimitExceeded),
683            _ => {}
684        }
685        let response: SourcifyResponse =
686            response.json().await.map_err(|e| EtherscanError::Unknown(e.to_string()))?;
687        trace!(target: "evm::traces::external", "Sourcify response for {address}: {response:#?}");
688        match response {
689            SourcifyResponse::Success(metadata) => Ok(Some(metadata.into())),
690            SourcifyResponse::Error(error) => Err(EtherscanError::Unknown(format!("{error:#?}"))),
691        }
692    }
693}
694
695/// Sourcify API response for `/v2/contract/{chainId}/{address}`.
696#[derive(Debug, Clone, Deserialize)]
697#[serde(untagged)]
698enum SourcifyResponse {
699    Success(SourcifyMetadata),
700    Error(SourcifyError),
701}
702
703#[derive(Debug, Clone, Deserialize)]
704#[serde(rename_all = "camelCase")]
705#[expect(dead_code)] // Used in Debug.
706struct SourcifyError {
707    custom_code: String,
708    message: String,
709    error_id: String,
710}
711
712#[derive(Debug, Clone, Deserialize)]
713#[serde(rename_all = "camelCase")]
714struct SourcifyMetadata {
715    #[serde(default)]
716    abi: Option<Box<serde_json::value::RawValue>>,
717    #[serde(default)]
718    compilation: Option<Compilation>,
719}
720
721#[derive(Debug, Clone, Deserialize)]
722#[serde(rename_all = "camelCase")]
723struct Compilation {
724    #[serde(default)]
725    compiler_version: String,
726    #[serde(default)]
727    name: String,
728}
729
730impl From<SourcifyMetadata> for Metadata {
731    fn from(metadata: SourcifyMetadata) -> Self {
732        let SourcifyMetadata { abi, compilation } = metadata;
733        let (contract_name, compiler_version) = compilation
734            .map(|c| (c.name, c.compiler_version))
735            .unwrap_or_else(|| (String::new(), String::new()));
736        // Defaulted fields may be fetched from sourcify but we don't make use of them.
737        Self {
738            source_code: foundry_block_explorers::contract::SourceCodeMetadata::Sources(
739                Default::default(),
740            ),
741            abi: Box::<str>::from(abi.unwrap_or_default()).into(),
742            contract_name,
743            compiler_version,
744            optimization_used: 0,
745            runs: 0,
746            constructor_arguments: Default::default(),
747            evm_version: String::new(),
748            library: String::new(),
749            license_type: String::new(),
750            proxy: 0,
751            implementation: None,
752            swarm_source: String::new(),
753        }
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use std::{
761        collections::HashSet as StdHashSet,
762        future::pending,
763        sync::{
764            Mutex,
765            atomic::{AtomicUsize, Ordering as AtomicOrdering},
766        },
767    };
768
769    struct TestFetcher {
770        kind: FetcherKind,
771        delay: Option<Duration>,
772        contract_name: Option<&'static str>,
773        calls: Arc<AtomicUsize>,
774        invalid: AtomicBool,
775    }
776
777    #[async_trait::async_trait]
778    impl ExternalFetcherT for TestFetcher {
779        fn kind(&self) -> FetcherKind {
780            self.kind
781        }
782
783        fn timeout(&self) -> Duration {
784            Duration::from_millis(1)
785        }
786
787        fn concurrency(&self) -> usize {
788            1
789        }
790
791        fn invalid_api_key(&self) -> &AtomicBool {
792            &self.invalid
793        }
794
795        async fn fetch(&self, _address: Address) -> Result<Option<Metadata>, EtherscanError> {
796            self.calls.fetch_add(1, AtomicOrdering::Relaxed);
797            let Some(delay) = self.delay else { return pending().await };
798            if !delay.is_zero() {
799                tokio::time::sleep(delay).await;
800            }
801            Ok(self.contract_name.map(metadata))
802        }
803    }
804
805    struct RateLimitedFetcher {
806        calls: Arc<AtomicUsize>,
807        invalid: AtomicBool,
808    }
809
810    #[async_trait::async_trait]
811    impl ExternalFetcherT for RateLimitedFetcher {
812        fn kind(&self) -> FetcherKind {
813            FetcherKind::Sourcify
814        }
815
816        fn timeout(&self) -> Duration {
817            Duration::from_millis(5)
818        }
819
820        fn concurrency(&self) -> usize {
821            1
822        }
823
824        fn invalid_api_key(&self) -> &AtomicBool {
825            &self.invalid
826        }
827
828        async fn fetch(&self, _address: Address) -> Result<Option<Metadata>, EtherscanError> {
829            self.calls.fetch_add(1, AtomicOrdering::Relaxed);
830            Err(EtherscanError::RateLimitExceeded)
831        }
832    }
833
834    struct ErrorFetcher {
835        calls: Arc<AtomicUsize>,
836        invalid: AtomicBool,
837    }
838
839    #[async_trait::async_trait]
840    impl ExternalFetcherT for ErrorFetcher {
841        fn kind(&self) -> FetcherKind {
842            FetcherKind::Etherscan
843        }
844
845        fn timeout(&self) -> Duration {
846            Duration::ZERO
847        }
848
849        fn concurrency(&self) -> usize {
850            1
851        }
852
853        fn invalid_api_key(&self) -> &AtomicBool {
854            &self.invalid
855        }
856
857        async fn fetch(&self, _address: Address) -> Result<Option<Metadata>, EtherscanError> {
858            self.calls.fetch_add(1, AtomicOrdering::Relaxed);
859            Err(EtherscanError::Unknown("temporary explorer failure".to_string()))
860        }
861    }
862
863    fn metadata(contract_name: &str) -> Metadata {
864        SourcifyMetadata {
865            abi: None,
866            compilation: Some(Compilation {
867                compiler_version: String::new(),
868                name: contract_name.to_string(),
869            }),
870        }
871        .into()
872    }
873
874    fn test_identifier(
875        fetchers: Vec<Arc<dyn ExternalFetcherT>>,
876        remaining_budget: Duration,
877    ) -> ExternalIdentifier {
878        ExternalIdentifier { fetchers, contracts: Default::default(), remaining_budget }
879    }
880
881    #[test]
882    fn zero_timeout_disables_external_identification() {
883        let mut config = Config::default();
884        config.tracing.external_identification_timeout = 0;
885
886        assert!(ExternalIdentifier::new(&config, Some(Chain::mainnet())).unwrap().is_none());
887    }
888
889    #[test]
890    fn storage_identifier_does_not_use_an_alias_for_another_chain() {
891        let config = ExternalIdentifierConfig {
892            timeout: 1,
893            etherscan: serde_json::from_value(serde_json::json!({
894                "mainnet": { "chain": 1, "key": "key" }
895            }))
896            .unwrap(),
897            etherscan_alias: Some("mainnet".to_string()),
898            etherscan_api_key: Some("mainnet".to_string()),
899            ..Default::default()
900        };
901
902        assert!(config.storage_identifier(Chain::from(8453)).is_none());
903    }
904
905    /// Fetcher that returns a transient Cloudflare block the first time it sees an address, then
906    /// succeeds. Mirrors Etherscan/Cloudflare throttling a burst of concurrent requests.
907    struct FlakyCloudflareFetcher {
908        seen: Mutex<StdHashSet<Address>>,
909        invalid: AtomicBool,
910    }
911
912    #[async_trait::async_trait]
913    impl ExternalFetcherT for FlakyCloudflareFetcher {
914        fn kind(&self) -> FetcherKind {
915            FetcherKind::Etherscan
916        }
917        fn timeout(&self) -> Duration {
918            Duration::from_millis(1)
919        }
920        fn concurrency(&self) -> usize {
921            1
922        }
923        fn invalid_api_key(&self) -> &AtomicBool {
924            &self.invalid
925        }
926        async fn fetch(&self, address: Address) -> Result<Option<Metadata>, EtherscanError> {
927            let first_time = self.seen.lock().unwrap().insert(address);
928            if first_time { Err(EtherscanError::BlockedByCloudflare) } else { Ok(None) }
929        }
930    }
931
932    /// Regression test for #9880: a transient Cloudflare block on one address must not abandon the
933    /// rest of the queue. Before the fix the fetcher returned `Poll::Ready(None)` on the first
934    /// block, ending the stream and leaving later addresses unidentified (partial trace decoding).
935    #[tokio::test]
936    async fn cloudflare_block_retries_instead_of_abandoning_queue() {
937        let addrs: Vec<Address> = (1u8..=4).map(Address::with_last_byte).collect();
938        let fetcher: Arc<dyn ExternalFetcherT> = Arc::new(FlakyCloudflareFetcher {
939            seen: Mutex::new(StdHashSet::new()),
940            invalid: AtomicBool::new(false),
941        });
942
943        let collected: Vec<_> = ExternalFetcher::new(fetcher, &addrs).collect().await;
944
945        let got: StdHashSet<Address> = collected.into_iter().map(|(addr, _)| addr).collect();
946        let want: StdHashSet<Address> = addrs.into_iter().collect();
947        assert_eq!(got, want, "every address must be yielded despite a transient cloudflare block");
948    }
949
950    #[tokio::test(start_paused = true)]
951    async fn timeout_keeps_partial_results_and_opens_circuit() {
952        let successful_calls = Arc::new(AtomicUsize::new(0));
953        let stalled_calls = Arc::new(AtomicUsize::new(0));
954        let fetchers: Vec<Arc<dyn ExternalFetcherT>> = vec![
955            Arc::new(TestFetcher {
956                kind: FetcherKind::Sourcify,
957                delay: Some(Duration::ZERO),
958                contract_name: Some("PartialResult"),
959                calls: Arc::clone(&successful_calls),
960                invalid: AtomicBool::new(false),
961            }),
962            Arc::new(TestFetcher {
963                kind: FetcherKind::Etherscan,
964                delay: None,
965                contract_name: None,
966                calls: Arc::clone(&stalled_calls),
967                invalid: AtomicBool::new(false),
968            }),
969        ];
970        let mut identifier = test_identifier(fetchers, Duration::from_millis(20));
971        let address = Address::with_last_byte(1);
972
973        identifier.fetch_addresses_async(&[address]).await;
974
975        assert!(identifier.remaining_budget.is_zero());
976        assert_eq!(
977            identifier.contracts[&address].1.as_ref().unwrap().contract_name,
978            "PartialResult"
979        );
980        assert_eq!(successful_calls.load(AtomicOrdering::Relaxed), 1);
981        assert_eq!(stalled_calls.load(AtomicOrdering::Relaxed), 1);
982
983        identifier.fetch_addresses_async(&[Address::with_last_byte(2)]).await;
984        assert_eq!(successful_calls.load(AtomicOrdering::Relaxed), 1);
985        assert_eq!(stalled_calls.load(AtomicOrdering::Relaxed), 1);
986    }
987
988    #[tokio::test(flavor = "multi_thread")]
989    async fn timeout_returns_partial_identity() {
990        let fetchers: Vec<Arc<dyn ExternalFetcherT>> = vec![
991            Arc::new(TestFetcher {
992                kind: FetcherKind::Sourcify,
993                delay: Some(Duration::ZERO),
994                contract_name: Some("PartialResult"),
995                calls: Arc::new(AtomicUsize::new(0)),
996                invalid: AtomicBool::new(false),
997            }),
998            Arc::new(TestFetcher {
999                kind: FetcherKind::Etherscan,
1000                delay: None,
1001                contract_name: None,
1002                calls: Arc::new(AtomicUsize::new(0)),
1003                invalid: AtomicBool::new(false),
1004            }),
1005        ];
1006        let mut identifier = test_identifier(fetchers, Duration::from_millis(20));
1007        let mut node = CallTraceNode::default();
1008        node.trace.address = Address::with_last_byte(1);
1009
1010        let identities = identifier.identify_addresses(&[&node]);
1011
1012        assert_eq!(identities.len(), 1);
1013        assert_eq!(identities[0].label.as_deref(), Some("PartialResult"));
1014    }
1015
1016    #[tokio::test(start_paused = true)]
1017    async fn timeout_budget_is_cumulative_across_fetches() {
1018        let calls = Arc::new(AtomicUsize::new(0));
1019        let fetcher: Arc<dyn ExternalFetcherT> = Arc::new(TestFetcher {
1020            kind: FetcherKind::Sourcify,
1021            delay: Some(Duration::from_millis(20)),
1022            contract_name: Some("FirstResult"),
1023            calls: Arc::clone(&calls),
1024            invalid: AtomicBool::new(false),
1025        });
1026        let mut identifier = test_identifier(vec![fetcher], Duration::from_millis(30));
1027        let first = Address::with_last_byte(1);
1028        let second = Address::with_last_byte(2);
1029
1030        identifier.fetch_addresses_async(&[first]).await;
1031        assert!(identifier.contracts[&first].1.is_some());
1032        assert!(identifier.remaining_budget < Duration::from_millis(15));
1033
1034        identifier.fetch_addresses_async(&[second]).await;
1035        assert!(identifier.remaining_budget.is_zero());
1036        assert!(!identifier.contracts.contains_key(&second));
1037        assert_eq!(calls.load(AtomicOrdering::Relaxed), 2);
1038    }
1039
1040    #[tokio::test(start_paused = true)]
1041    async fn metadata_requests_preserve_cumulative_budget() {
1042        let calls = Arc::new(AtomicUsize::new(0));
1043        let fetcher: Arc<dyn ExternalFetcherT> = Arc::new(TestFetcher {
1044            kind: FetcherKind::Etherscan,
1045            delay: None,
1046            contract_name: None,
1047            calls: Arc::clone(&calls),
1048            invalid: AtomicBool::new(false),
1049        });
1050        let mut identifier = test_identifier(vec![fetcher], Duration::from_millis(30));
1051        let address = Address::with_last_byte(1);
1052
1053        assert!(identifier.get_metadata(&[address], Duration::from_millis(10)).await.is_empty());
1054        assert!(!identifier.remaining_budget.is_zero());
1055        assert!(identifier.remaining_budget <= Duration::from_millis(20));
1056        assert!(identifier.get_metadata(&[address], Duration::from_secs(1)).await.is_empty());
1057        assert!(identifier.remaining_budget.is_zero());
1058        assert!(identifier.get_metadata(&[address], Duration::from_secs(1)).await.is_empty());
1059        assert_eq!(calls.load(AtomicOrdering::Relaxed), 2);
1060    }
1061
1062    #[tokio::test(start_paused = true)]
1063    async fn rate_limit_retries_cannot_escape_timeout_budget() {
1064        let calls = Arc::new(AtomicUsize::new(0));
1065        let fetcher: Arc<dyn ExternalFetcherT> = Arc::new(RateLimitedFetcher {
1066            calls: Arc::clone(&calls),
1067            invalid: AtomicBool::new(false),
1068        });
1069        let mut identifier = test_identifier(vec![fetcher], Duration::from_millis(20));
1070
1071        identifier.fetch_addresses_async(&[Address::with_last_byte(1)]).await;
1072
1073        assert!(identifier.remaining_budget.is_zero());
1074        assert!(calls.load(AtomicOrdering::Relaxed) > 1);
1075    }
1076
1077    #[tokio::test]
1078    async fn transient_errors_are_not_cached_as_unverified() {
1079        let calls = Arc::new(AtomicUsize::new(0));
1080        let mut identifier = test_identifier(
1081            vec![Arc::new(ErrorFetcher {
1082                calls: Arc::clone(&calls),
1083                invalid: AtomicBool::new(false),
1084            })],
1085            Duration::from_secs(1),
1086        );
1087        let address = Address::with_last_byte(1);
1088
1089        assert!(identifier.get_metadata(&[address], Duration::from_secs(1)).await.is_empty());
1090        assert!(identifier.get_metadata(&[address], Duration::from_secs(1)).await.is_empty());
1091        assert_eq!(calls.load(AtomicOrdering::Relaxed), 2);
1092    }
1093
1094    #[test]
1095    fn etherscan_metadata_takes_precedence() {
1096        let address = Address::with_last_byte(1);
1097        let mut identifier = test_identifier(Vec::new(), Duration::ZERO);
1098
1099        identifier
1100            .cache_fetched(address, (FetcherKind::Sourcify, Some(metadata("SourcifyResult"))));
1101        identifier.cache_fetched(address, (FetcherKind::Etherscan, None));
1102        assert_eq!(
1103            identifier.contracts[&address].1.as_ref().unwrap().contract_name,
1104            "SourcifyResult"
1105        );
1106
1107        identifier
1108            .cache_fetched(address, (FetcherKind::Etherscan, Some(metadata("EtherscanResult"))));
1109        assert_eq!(
1110            identifier.contracts[&address].1.as_ref().unwrap().contract_name,
1111            "EtherscanResult"
1112        );
1113    }
1114
1115    #[tokio::test]
1116    async fn proxy_metadata_preserves_address_identity_and_all_abis() {
1117        let proxy = Address::with_last_byte(1);
1118        let implementation_address = Address::with_last_byte(2);
1119        let mut proxy_metadata = metadata("Proxy");
1120        proxy_metadata.abi =
1121            r#"[{"anonymous":false,"inputs":[],"name":"ProxyEvent","type":"event"}]"#.to_string();
1122        proxy_metadata.proxy = 1;
1123        proxy_metadata.implementation = Some(implementation_address);
1124        let mut implementation = metadata("Implementation");
1125        implementation.abi =
1126            r#"[{"anonymous":false,"inputs":[],"name":"ImplementationEvent","type":"event"}]"#
1127                .to_string();
1128        let mut identifier = test_identifier(Vec::new(), Duration::from_secs(1));
1129        let identity = identifier.identify_from_metadata(proxy, &proxy_metadata);
1130        assert_eq!(identity.contract.as_deref(), Some("Proxy"));
1131        identifier.cache_fetched(proxy, (FetcherKind::Etherscan, Some(proxy_metadata)));
1132        identifier
1133            .cache_fetched(implementation_address, (FetcherKind::Etherscan, Some(implementation)));
1134
1135        let mut results = identifier.get_abis(&[proxy]).await;
1136        let (result_address, result) = results.pop().unwrap();
1137        let (abis, complete) = result.unwrap();
1138        let event_names =
1139            abis.into_iter().map(|abi| abi.events.into_keys().next().unwrap()).collect::<Vec<_>>();
1140
1141        assert_eq!(result_address, proxy);
1142        assert!(complete);
1143        assert_eq!(event_names, ["ImplementationEvent", "ProxyEvent"]);
1144
1145        identifier.contracts.remove(&implementation_address);
1146        let (_, result) = identifier.get_abis(&[proxy]).await.pop().unwrap();
1147        let (abis, complete) = result.unwrap();
1148        assert_eq!(abis.len(), 1);
1149        assert!(!complete);
1150    }
1151
1152    #[tokio::test]
1153    async fn storage_metadata_does_not_follow_explorer_proxy_hints() {
1154        let proxy = Address::with_last_byte(1);
1155        let implementation_address = Address::with_last_byte(2);
1156        let mut proxy_metadata = metadata("Proxy");
1157        proxy_metadata.proxy = 1;
1158        proxy_metadata.implementation = Some(implementation_address);
1159        let mut identifier = test_identifier(Vec::new(), Duration::from_secs(1));
1160        identifier.cache_fetched(proxy, (FetcherKind::Etherscan, Some(proxy_metadata)));
1161        identifier.cache_fetched(
1162            implementation_address,
1163            (FetcherKind::Etherscan, Some(metadata("CurrentImplementation"))),
1164        );
1165
1166        let result = identifier.get_metadata(&[proxy], Duration::from_secs(1)).await;
1167
1168        assert_eq!(result[&proxy].as_ref().unwrap().contract_name, "Proxy");
1169        assert_eq!(result.len(), 1);
1170    }
1171}