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
29pub struct ExternalIdentifier {
31 fetchers: Vec<Arc<dyn ExternalFetcherT>>,
32 contracts: HashMap<Address, (FetcherKind, Option<Metadata>)>,
34 remaining_budget: Duration,
36}
37
38#[derive(Clone, Debug, Default)]
44pub struct ExternalIdentifierConfig {
45 offline: bool,
47 timeout: u64,
49 no_proxy: bool,
51 etherscan: EtherscanConfigs,
53 etherscan_alias: Option<String>,
54 etherscan_api_key: Option<String>,
55 chain: Option<Chain>,
57}
58
59impl ExternalIdentifierConfig {
60 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 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 pub fn storage_identifier(&self, chain: Chain) -> Option<ExternalIdentifier> {
92 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 pub const fn storage_timeout(&self) -> Duration {
157 Duration::from_secs(self.timeout)
158 }
159}
160
161impl ExternalIdentifier {
162 pub fn new(config: &Config, chain: Option<Chain>) -> eyre::Result<Option<Self>> {
164 Ok(ExternalIdentifierConfig::new(config).identifier(chain))
165 }
166
167 pub async fn get_compiled_contracts(&self) -> eyre::Result<ContractSources> {
170 let contracts_info: Vec<_> = self
172 .contracts
173 .iter()
174 .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 let outputs = join_all(outputs_fut).await;
204
205 let mut sources: ContractSources = Default::default();
206
207 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 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 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(¤t) 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 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 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 }
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
445const 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
453struct ExternalFetcher {
457 fetcher: Arc<dyn ExternalFetcherT>,
459 timeout: Duration,
461 backoff: Option<Interval>,
463 concurrency: usize,
465 queue: Vec<Address>,
467 in_progress: FuturesUnordered<FetchFuture>,
469 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 pin.fetcher.invalid_api_key().store(true, Ordering::Relaxed);
545 return Poll::Ready(None);
546 }
547 Err(EtherscanError::BlockedByCloudflare) => {
548 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 }
567 }
568 Err(err) => {
569 warn!(target: "evm::traces::external", ?err, "could not get info");
570 }
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 404 => return Err(EtherscanError::ContractCodeNotVerified(address)),
681 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#[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)] struct 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 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 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 #[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}