Skip to main content

foundry_evm_traces/identifier/
etherscan.rs

1use super::{IdentifiedAddress, TraceIdentifier};
2use crate::debug::ContractSources;
3use alloy_primitives::Address;
4use foundry_block_explorers::{
5    contract::{ContractMetadata, Metadata},
6    errors::EtherscanError,
7};
8use foundry_common::compile::etherscan_project;
9use foundry_config::{Chain, Config};
10use futures::{
11    future::join_all,
12    stream::{FuturesUnordered, Stream, StreamExt},
13    task::{Context, Poll},
14};
15use revm_inspectors::tracing::types::CallTraceNode;
16use std::{
17    borrow::Cow,
18    collections::BTreeMap,
19    pin::Pin,
20    sync::{
21        Arc,
22        atomic::{AtomicBool, Ordering},
23    },
24};
25use tokio::time::{Duration, Interval};
26
27/// A trace identifier that tries to identify addresses using Etherscan.
28pub struct EtherscanIdentifier {
29    /// The Etherscan client
30    client: Arc<foundry_block_explorers::Client>,
31    /// Tracks whether the API key provides was marked as invalid
32    ///
33    /// After the first [EtherscanError::InvalidApiKey] this will get set to true, so we can
34    /// prevent any further attempts
35    invalid_api_key: Arc<AtomicBool>,
36    pub contracts: BTreeMap<Address, Metadata>,
37    pub sources: BTreeMap<u32, String>,
38}
39
40impl EtherscanIdentifier {
41    /// Creates a new Etherscan identifier with the given client
42    pub fn new(config: &Config, chain: Option<Chain>) -> eyre::Result<Option<Self>> {
43        // In offline mode, don't use Etherscan.
44        if config.offline {
45            return Ok(None);
46        }
47
48        let config = match config.get_etherscan_config_with_chain(chain) {
49            Ok(Some(config)) => config,
50            Ok(None) => {
51                warn!(target: "traces::etherscan", "etherscan config not found");
52                return Ok(None);
53            }
54            Err(err) => {
55                warn!(?err, "failed to get etherscan config");
56                return Ok(None);
57            }
58        };
59
60        trace!(target: "traces::etherscan", chain=?config.chain, url=?config.api_url, "using etherscan identifier");
61        Ok(Some(Self {
62            client: Arc::new(config.into_client()?),
63            invalid_api_key: Arc::new(AtomicBool::new(false)),
64            contracts: BTreeMap::new(),
65            sources: BTreeMap::new(),
66        }))
67    }
68
69    /// Goes over the list of contracts we have pulled from the traces, clones their source from
70    /// Etherscan and compiles them locally, for usage in the debugger.
71    pub async fn get_compiled_contracts(&self) -> eyre::Result<ContractSources> {
72        // TODO: Add caching so we dont double-fetch contracts.
73        let outputs_fut = self
74            .contracts
75            .iter()
76            // filter out vyper files
77            .filter(|(_, metadata)| !metadata.is_vyper())
78            .map(|(address, metadata)| async move {
79                sh_println!("Compiling: {} {address}", metadata.contract_name)?;
80                let root = tempfile::tempdir()?;
81                let root_path = root.path();
82                let project = etherscan_project(metadata, root_path)?;
83                let output = project.compile()?;
84
85                if output.has_compiler_errors() {
86                    eyre::bail!("{output}")
87                }
88
89                Ok((project, output, root))
90            })
91            .collect::<Vec<_>>();
92
93        // poll all the futures concurrently
94        let outputs = join_all(outputs_fut).await;
95
96        let mut sources: ContractSources = Default::default();
97
98        // construct the map
99        for res in outputs {
100            let (project, output, _root) = res?;
101            sources.insert(&output, project.root(), None)?;
102        }
103
104        Ok(sources)
105    }
106
107    fn identify_from_metadata(
108        &self,
109        address: Address,
110        metadata: &Metadata,
111    ) -> IdentifiedAddress<'static> {
112        let label = metadata.contract_name.clone();
113        let abi = metadata.abi().ok().map(Cow::Owned);
114        IdentifiedAddress {
115            address,
116            label: Some(label.clone()),
117            contract: Some(label),
118            abi,
119            artifact_id: None,
120        }
121    }
122}
123
124impl TraceIdentifier for EtherscanIdentifier {
125    fn identify_addresses(&mut self, nodes: &[&CallTraceNode]) -> Vec<IdentifiedAddress<'_>> {
126        if self.invalid_api_key.load(Ordering::Relaxed) || nodes.is_empty() {
127            return Vec::new();
128        }
129
130        trace!(target: "evm::traces::etherscan", "identify {} addresses", nodes.len());
131
132        let mut identities = Vec::new();
133        let mut fetcher = EtherscanFetcher::new(
134            self.client.clone(),
135            Duration::from_secs(1),
136            5,
137            Arc::clone(&self.invalid_api_key),
138        );
139
140        for &node in nodes {
141            let address = node.trace.address;
142            if let Some(metadata) = self.contracts.get(&address) {
143                identities.push(self.identify_from_metadata(address, metadata));
144            } else {
145                fetcher.push(address);
146            }
147        }
148
149        let fetched_identities = foundry_common::block_on(
150            fetcher
151                .map(|(address, metadata)| {
152                    let addr = self.identify_from_metadata(address, &metadata);
153                    self.contracts.insert(address, metadata);
154                    addr
155                })
156                .collect::<Vec<IdentifiedAddress<'_>>>(),
157        );
158
159        identities.extend(fetched_identities);
160        identities
161    }
162}
163
164type EtherscanFuture =
165    Pin<Box<dyn Future<Output = (Address, Result<ContractMetadata, EtherscanError>)>>>;
166
167/// A rate limit aware Etherscan client.
168///
169/// Fetches information about multiple addresses concurrently, while respecting rate limits.
170struct EtherscanFetcher {
171    /// The Etherscan client
172    client: Arc<foundry_block_explorers::Client>,
173    /// The time we wait if we hit the rate limit
174    timeout: Duration,
175    /// The interval we are currently waiting for before making a new request
176    backoff: Option<Interval>,
177    /// The maximum amount of requests to send concurrently
178    concurrency: usize,
179    /// The addresses we have yet to make requests for
180    queue: Vec<Address>,
181    /// The in progress requests
182    in_progress: FuturesUnordered<EtherscanFuture>,
183    /// tracks whether the API key provides was marked as invalid
184    invalid_api_key: Arc<AtomicBool>,
185}
186
187impl EtherscanFetcher {
188    fn new(
189        client: Arc<foundry_block_explorers::Client>,
190        timeout: Duration,
191        concurrency: usize,
192        invalid_api_key: Arc<AtomicBool>,
193    ) -> Self {
194        Self {
195            client,
196            timeout,
197            backoff: None,
198            concurrency,
199            queue: Vec::new(),
200            in_progress: FuturesUnordered::new(),
201            invalid_api_key,
202        }
203    }
204
205    fn push(&mut self, address: Address) {
206        self.queue.push(address);
207    }
208
209    fn queue_next_reqs(&mut self) {
210        while self.in_progress.len() < self.concurrency {
211            let Some(addr) = self.queue.pop() else { break };
212            let client = Arc::clone(&self.client);
213            self.in_progress.push(Box::pin(async move {
214                trace!(target: "traces::etherscan", ?addr, "fetching info");
215                let res = client.contract_source_code(addr).await;
216                (addr, res)
217            }));
218        }
219    }
220}
221
222impl Stream for EtherscanFetcher {
223    type Item = (Address, Metadata);
224
225    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
226        let pin = self.get_mut();
227
228        loop {
229            if let Some(mut backoff) = pin.backoff.take()
230                && backoff.poll_tick(cx).is_pending()
231            {
232                pin.backoff = Some(backoff);
233                return Poll::Pending;
234            }
235
236            pin.queue_next_reqs();
237
238            let mut made_progress_this_iter = false;
239            match pin.in_progress.poll_next_unpin(cx) {
240                Poll::Pending => {}
241                Poll::Ready(None) => return Poll::Ready(None),
242                Poll::Ready(Some((addr, res))) => {
243                    made_progress_this_iter = true;
244                    match res {
245                        Ok(mut metadata) => {
246                            if let Some(item) = metadata.items.pop() {
247                                return Poll::Ready(Some((addr, item)));
248                            }
249                        }
250                        Err(EtherscanError::RateLimitExceeded) => {
251                            warn!(target: "traces::etherscan", "rate limit exceeded on attempt");
252                            pin.backoff = Some(tokio::time::interval(pin.timeout));
253                            pin.queue.push(addr);
254                        }
255                        Err(EtherscanError::InvalidApiKey) => {
256                            warn!(target: "traces::etherscan", "invalid api key");
257                            // mark key as invalid
258                            pin.invalid_api_key.store(true, Ordering::Relaxed);
259                            return Poll::Ready(None);
260                        }
261                        Err(EtherscanError::BlockedByCloudflare) => {
262                            warn!(target: "traces::etherscan", "blocked by cloudflare");
263                            // mark key as invalid
264                            pin.invalid_api_key.store(true, Ordering::Relaxed);
265                            return Poll::Ready(None);
266                        }
267                        Err(err) => {
268                            warn!(target: "traces::etherscan", "could not get etherscan info: {:?}", err);
269                        }
270                    }
271                }
272            }
273
274            if !made_progress_this_iter {
275                return Poll::Pending;
276            }
277        }
278    }
279}