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