1use crate::fs;
19use alloy_chains::Chain;
20use alloy_primitives::{Address, map::AddressMap};
21use foundry_block_explorers::contract::Metadata;
22use foundry_compilers::{
23 artifacts::{
24 CompilerOutput, SolcInput, SolcLanguage, Source, Sources, StorageLayout,
25 output_selection::OutputSelection,
26 },
27 solc::Solc,
28};
29use foundry_config::Config;
30use serde::{Deserialize, Serialize};
31use std::{
32 io::{Read, Seek, SeekFrom},
33 path::{Path, PathBuf},
34 process::{Command, Stdio},
35 sync::{
36 Arc, LazyLock, Mutex, MutexGuard, TryLockError,
37 atomic::{AtomicU64, Ordering},
38 },
39 time::{Duration, Instant},
40};
41use wait_timeout::ChildExt;
42
43const MIN_STORAGE_LAYOUT_SOLC: semver::Version = semver::Version::new(0, 6, 5);
45const BASE_PATH_SOLC: semver::Version = semver::Version::new(0, 6, 9);
47const NO_IMPORT_CALLBACK_SOLC: semver::Version = semver::Version::new(0, 8, 22);
49
50type ExternalStorageLayout = Option<(String, Arc<StorageLayout>)>;
56
57type LookupSlot = Arc<Mutex<Option<ExternalStorageLayout>>>;
59
60static LOOKUPS: LazyLock<Mutex<std::collections::HashMap<(u64, Address), LookupSlot>>> =
66 LazyLock::new(Default::default);
67
68static COMPILER: Mutex<()> = Mutex::new(());
70
71static CACHE_TEMP_ID: AtomicU64 = AtomicU64::new(0);
73
74pub fn fetch_external_storage_layouts(
87 chain: Chain,
88 addresses: impl IntoIterator<Item = Address>,
89 timeout: Duration,
90 fetch_sources: impl FnOnce(&[Address], Duration) -> AddressMap<Option<Metadata>>,
91) -> AddressMap<(String, Arc<StorageLayout>)> {
92 let cache_dir = Config::foundry_etherscan_chain_cache_dir(chain);
93 resolve(chain.id(), cache_dir.as_deref(), addresses, timeout, fetch_sources)
94}
95
96fn resolve(
99 chain_id: u64,
100 cache_dir: Option<&Path>,
101 addresses: impl IntoIterator<Item = Address>,
102 timeout: Duration,
103 fetch_sources: impl FnOnce(&[Address], Duration) -> AddressMap<Option<Metadata>>,
104) -> AddressMap<(String, Arc<StorageLayout>)> {
105 let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now);
106 let mut resolved = AddressMap::default();
107
108 let mut addresses = addresses.into_iter().collect::<Vec<_>>();
111 addresses.sort_unstable();
112 addresses.dedup();
113 let slots = {
114 let Some(mut lookups) = lock_until(&LOOKUPS, deadline) else {
115 warn!(target: "external-storage", "external storage lookup timed out");
116 return resolved;
117 };
118 addresses
119 .into_iter()
120 .map(|address| (address, lookups.entry((chain_id, address)).or_default().clone()))
121 .collect::<Vec<_>>()
122 };
123
124 let mut pending = Vec::new();
125 for (address, slot) in &slots {
126 let Some(guard) = lock_until(slot, deadline) else {
128 warn!(target: "external-storage", %address, "external storage lookup timed out");
129 return resolved;
130 };
131 if let Some(cached) = &*guard {
132 if let Some((name, layout)) = cached {
133 resolved.insert(*address, (name.clone(), layout.clone()));
134 }
135 continue;
136 }
137 pending.push((*address, guard));
138 }
139
140 if pending.is_empty() {
141 return resolved;
142 }
143
144 pending.retain_mut(|(address, guard)| {
147 let Some(cached) = read_cached_layout(cache_dir, *address) else {
148 return true;
149 };
150 resolved.insert(*address, cached.clone());
151 **guard = Some(Some(cached));
152 false
153 });
154
155 if pending.is_empty() {
156 return resolved;
157 }
158
159 let remaining = deadline.saturating_duration_since(Instant::now());
160 if remaining.is_zero() {
161 warn!(target: "external-storage", "external storage lookup timed out");
162 return resolved;
163 }
164 let sources =
165 fetch_sources(&pending.iter().map(|(address, _)| *address).collect::<Vec<_>>(), remaining);
166
167 for (address, mut guard) in pending {
168 let Some(source) = sources.get(&address) else {
169 continue;
172 };
173
174 let Some(metadata) = source else {
175 *guard = Some(None);
177 continue;
178 };
179
180 let layout = compile_storage_layout(address, metadata, deadline);
181 if layout.is_none() && Instant::now() >= deadline {
182 continue;
185 }
186 if let Some((name, layout)) = &layout {
187 resolved.insert(address, (name.clone(), layout.clone()));
188 write_cached_layout(cache_dir, address, name, layout);
189 }
190 *guard = Some(layout);
193 }
194
195 resolved
196}
197
198fn compile_storage_layout(
200 address: Address,
201 metadata: &Metadata,
202 deadline: Instant,
203) -> Option<(String, Arc<StorageLayout>)> {
204 if metadata.is_vyper() {
205 trace!(target: "external-storage", %address, "skipping vyper contract");
206 return None;
207 }
208
209 match metadata.compiler_version() {
213 Ok(version) if version < MIN_STORAGE_LAYOUT_SOLC => {
214 trace!(target: "external-storage", %address, %version, "solc too old for storage layouts");
215 return None;
216 }
217 Ok(_) => {}
218 Err(err) => {
219 warn!(target: "external-storage", %address, %err, "could not read compiler version");
220 return None;
221 }
222 }
223
224 let sources = metadata
225 .sources()
226 .into_iter()
227 .map(|(path, source)| (PathBuf::from(path), Source::new(source.content)))
228 .collect::<Sources>();
229 if sources.is_empty() {
230 trace!(target: "external-storage", %address, "verified metadata has no sources");
231 return None;
232 }
233 let Some(_compiler) = lock_until(&COMPILER, deadline) else {
234 warn!(target: "external-storage", %address, "external storage compilation timed out");
235 return None;
236 };
237
238 let version = metadata.compiler_version().ok()?;
242 let mut settings = match metadata.settings() {
243 Ok(settings) => settings,
244 Err(err) => {
245 warn!(target: "external-storage", %address, %err, "failed to read compiler settings");
246 return None;
247 }
248 };
249 settings.output_selection =
250 OutputSelection::common_output_selection(["storageLayout".to_string()]);
251 let input = SolcInput::new(SolcLanguage::Solidity, sources, settings).sanitized(&version);
252 let svm_version = semver::Version::new(version.major, version.minor, version.patch);
253 let solc = match Solc::find_svm_installed_version(&svm_version) {
254 Ok(Some(solc)) => solc,
255 Ok(None) => {
256 let remaining = deadline.saturating_duration_since(Instant::now());
257 let installed =
258 crate::block_on(tokio::time::timeout(remaining, Solc::install(&svm_version)));
259 match installed {
260 Ok(Ok(solc)) => solc,
261 Ok(Err(err)) => {
262 warn!(target: "external-storage", %address, %err, "failed to install compiler");
263 return None;
264 }
265 Err(_) => {
266 warn!(target: "external-storage", %address, "compiler installation timed out");
267 return None;
268 }
269 }
270 }
271 Err(err) => {
272 warn!(target: "external-storage", %address, %err, "failed to find compiler");
273 return None;
274 }
275 };
276 let remaining = deadline.saturating_duration_since(Instant::now());
277 let output = match run_solc(&solc, &version, &input, remaining) {
278 Ok(output) => output,
279 Err(err) => {
280 warn!(target: "external-storage", %address, %err, "failed to compile contract");
281 return None;
282 }
283 };
284
285 let name = metadata.contract_name.clone();
286 let mut matches = output
287 .contracts
288 .values()
289 .filter_map(|contracts| contracts.get(&name))
290 .filter(|contract| !contract.storage_layout.storage.is_empty());
291 let layout = matches.next().map(|contract| contract.storage_layout.clone());
292 if matches.next().is_some() {
293 warn!(target: "external-storage", %address, %name, "multiple artifacts match contract name");
294 return None;
295 }
296
297 let Some(layout) = layout else {
298 warn!(target: "external-storage", %address, %name, "no storage layout in compiled artifacts");
299 return None;
300 };
301
302 Some((name, Arc::new(layout)))
303}
304
305fn run_solc(
307 solc: &Solc,
308 version: &semver::Version,
309 input: &SolcInput,
310 timeout: Duration,
311) -> Result<CompilerOutput, String> {
312 if timeout.is_zero() {
313 return Err("compilation timed out".to_string());
314 }
315
316 let sandbox = tempfile::tempdir().map_err(|err| err.to_string())?;
317 let mut stdin = tempfile::tempfile().map_err(|err| err.to_string())?;
318 serde_json::to_writer(&mut stdin, input).map_err(|err| err.to_string())?;
319 stdin.seek(SeekFrom::Start(0)).map_err(|err| err.to_string())?;
320 let mut stdout = tempfile::tempfile().map_err(|err| err.to_string())?;
321 let mut stderr = tempfile::tempfile().map_err(|err| err.to_string())?;
322
323 let mut command = Command::new(&solc.solc);
324 command.arg("--standard-json").current_dir(sandbox.path());
325 if version >= &BASE_PATH_SOLC {
326 command.arg("--base-path").arg(sandbox.path());
327 }
328 if version >= &NO_IMPORT_CALLBACK_SOLC {
329 command.arg("--no-import-callback");
330 }
331 command
332 .stdin(Stdio::from(stdin))
333 .stdout(Stdio::from(stdout.try_clone().map_err(|err| err.to_string())?))
334 .stderr(Stdio::from(stderr.try_clone().map_err(|err| err.to_string())?));
335
336 let mut child = command.spawn().map_err(|err| err.to_string())?;
337 let status = match child.wait_timeout(timeout) {
338 Ok(Some(status)) => status,
339 Ok(None) => {
340 let _ = child.kill();
341 let _ = child.wait();
342 return Err("compilation timed out".to_string());
343 }
344 Err(err) => {
345 let _ = child.kill();
346 let _ = child.wait();
347 return Err(err.to_string());
348 }
349 };
350 if !status.success() {
351 stderr.seek(SeekFrom::Start(0)).map_err(|err| err.to_string())?;
352 let mut message = String::new();
353 stderr.read_to_string(&mut message).map_err(|err| err.to_string())?;
354 return Err(if message.trim().is_empty() {
355 format!("solc exited with {status}")
356 } else {
357 message
358 });
359 }
360
361 stdout.seek(SeekFrom::Start(0)).map_err(|err| err.to_string())?;
362 serde_json::from_reader(stdout).map_err(|err| err.to_string())
363}
364
365#[derive(Serialize, Deserialize)]
367#[serde(rename_all = "camelCase")]
368struct CachedStorageLayout {
369 version: u32,
372 contract_name: String,
373 storage_layout: StorageLayout,
374}
375
376const CACHE_VERSION: u32 = 2;
378
379fn cache_path(cache_dir: &Path, address: Address) -> PathBuf {
381 cache_dir.join("storage_layouts").join(format!("{address}.json"))
382}
383
384fn read_cached_layout(
386 cache_dir: Option<&Path>,
387 address: Address,
388) -> Option<(String, Arc<StorageLayout>)> {
389 let path = cache_path(cache_dir?, address);
390 let cached = fs::read_json_file::<CachedStorageLayout>(&path).ok()?;
391 if cached.version != CACHE_VERSION {
392 trace!(target: "external-storage", %address, cached.version, "ignoring stale cache entry");
393 return None;
394 }
395 trace!(target: "external-storage", %address, "using cached storage layout");
396 Some((cached.contract_name, Arc::new(cached.storage_layout)))
397}
398
399fn write_cached_layout(
404 cache_dir: Option<&Path>,
405 address: Address,
406 name: &str,
407 layout: &StorageLayout,
408) {
409 let Some(cache_dir) = cache_dir else { return };
410 let path = cache_path(cache_dir, address);
411 let Some(parent) = path.parent() else { return };
412 if let Err(err) = std::fs::create_dir_all(parent) {
413 warn!(target: "external-storage", %address, %err, "failed to create storage layout cache");
414 return;
415 }
416
417 let cached = CachedStorageLayout {
418 version: CACHE_VERSION,
419 contract_name: name.to_string(),
420 storage_layout: layout.clone(),
421 };
422
423 let tmp_path = path.with_extension(format!(
426 "json.{}.{}.tmp",
427 std::process::id(),
428 CACHE_TEMP_ID.fetch_add(1, Ordering::Relaxed)
429 ));
430 match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp_path) {
431 Ok(_) => {}
432 Err(err) => {
433 warn!(target: "external-storage", %address, %err, "failed to create cache temporary file");
434 return;
435 }
436 }
437 let write = fs::write_json_file(&tmp_path, &cached).and_then(|()| {
438 std::fs::rename(&tmp_path, &path)
439 .map_err(|err| crate::errors::FsPathError::write(err, path.as_path()))
440 });
441 if let Err(err) = write {
442 warn!(target: "external-storage", %address, %err, "failed to cache storage layout");
443 let _ = std::fs::remove_file(&tmp_path);
444 }
445}
446
447pub fn lock_until<T>(mutex: &Mutex<T>, deadline: Instant) -> Option<MutexGuard<'_, T>> {
451 loop {
452 match mutex.try_lock() {
453 Ok(guard) => return Some(guard),
454 Err(TryLockError::Poisoned(err)) => return Some(err.into_inner()),
455 Err(TryLockError::WouldBlock) => {
456 let remaining = deadline.saturating_duration_since(Instant::now());
457 if remaining.is_zero() {
458 return None;
459 }
460 std::thread::sleep(remaining.min(Duration::from_millis(1)));
461 }
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use foundry_block_explorers::contract::SourceCodeMetadata;
470
471 #[cfg(unix)]
472 use std::os::unix::fs::PermissionsExt;
473
474 fn next_chain_id() -> u64 {
477 static NEXT: AtomicU64 = AtomicU64::new(1);
478 NEXT.fetch_add(1, Ordering::Relaxed)
479 }
480
481 #[test]
482 fn round_trips_a_resolved_layout() {
483 let cache_dir = tempfile::tempdir().unwrap();
484 let address = Address::with_last_byte(1);
485
486 assert!(read_cached_layout(Some(cache_dir.path()), address).is_none());
488
489 let layout = StorageLayout::default();
490 write_cached_layout(Some(cache_dir.path()), address, "Counter", &layout);
491
492 let (name, cached) = read_cached_layout(Some(cache_dir.path()), address).unwrap();
493 assert_eq!(name, "Counter");
494 assert_eq!(*cached, layout);
495 }
496
497 #[test]
498 fn ignores_cache_entries_written_by_another_version() {
499 let cache_dir = tempfile::tempdir().unwrap();
500 let address = Address::with_last_byte(1);
501 write_cached_layout(Some(cache_dir.path()), address, "Counter", &StorageLayout::default());
502
503 let path = cache_path(cache_dir.path(), address);
504 let stale = std::fs::read_to_string(&path)
505 .unwrap()
506 .replace(&format!("\"version\":{CACHE_VERSION}"), "\"version\":0");
507 std::fs::write(&path, stale).unwrap();
508
509 assert!(read_cached_layout(Some(cache_dir.path()), address).is_none());
510 }
511
512 #[test]
513 fn write_leaves_no_temporary_files_behind() {
514 let cache_dir = tempfile::tempdir().unwrap();
515 let address = Address::with_last_byte(1);
516 write_cached_layout(Some(cache_dir.path()), address, "Counter", &StorageLayout::default());
517
518 let entries = std::fs::read_dir(cache_dir.path().join("storage_layouts"))
519 .unwrap()
520 .map(|entry| entry.unwrap().file_name())
521 .collect::<Vec<_>>();
522 assert_eq!(entries, [format!("{address}.json").as_str()]);
523 }
524
525 #[test]
526 fn serves_the_disk_cache_without_looking_anything_up() {
527 let cache_dir = tempfile::tempdir().unwrap();
528 let chain_id = next_chain_id();
529 let cached = Address::with_last_byte(1);
530 let fresh = Address::with_last_byte(2);
531 write_cached_layout(Some(cache_dir.path()), cached, "Counter", &StorageLayout::default());
532
533 let mut asked_for = Vec::new();
534 let resolved = resolve(
535 chain_id,
536 Some(cache_dir.path()),
537 [cached, fresh],
538 Duration::from_secs(1),
539 |addresses, _| {
540 asked_for.extend_from_slice(addresses);
541 AddressMap::default()
542 },
543 );
544
545 assert_eq!(asked_for, [fresh]);
547 assert_eq!(resolved.keys().copied().collect::<Vec<_>>(), [cached]);
548 assert_eq!(resolved[&cached].0, "Counter");
549 }
550
551 #[test]
552 fn remembers_a_conclusive_miss_for_the_rest_of_the_run() {
553 let cache_dir = tempfile::tempdir().unwrap();
554 let chain_id = next_chain_id();
555 let address = Address::with_last_byte(1);
556
557 let mut lookups = 0;
558 let mut unverified = |addresses: &[Address], _: Duration| {
559 lookups += 1;
560 addresses.iter().map(|address| (*address, None)).collect::<AddressMap<_>>()
561 };
562
563 assert!(
564 resolve(
565 chain_id,
566 Some(cache_dir.path()),
567 [address],
568 Duration::from_secs(1),
569 &mut unverified,
570 )
571 .is_empty()
572 );
573 assert!(
574 resolve(
575 chain_id,
576 Some(cache_dir.path()),
577 [address],
578 Duration::from_secs(1),
579 &mut unverified,
580 )
581 .is_empty()
582 );
583 assert_eq!(lookups, 1, "an unverified contract should only be looked up once");
584 assert!(!cache_path(cache_dir.path(), address).exists());
585 }
586
587 #[test]
588 fn retries_an_address_the_lookup_reached_no_conclusion_about() {
589 let cache_dir = tempfile::tempdir().unwrap();
590 let chain_id = next_chain_id();
591 let address = Address::with_last_byte(1);
592
593 let mut lookups = 0;
595 let mut unavailable = |_: &[Address], _: Duration| {
596 lookups += 1;
597 AddressMap::default()
598 };
599
600 assert!(
601 resolve(
602 chain_id,
603 Some(cache_dir.path()),
604 [address],
605 Duration::from_secs(1),
606 &mut unavailable,
607 )
608 .is_empty()
609 );
610 assert!(
611 resolve(
612 chain_id,
613 Some(cache_dir.path()),
614 [address],
615 Duration::from_secs(1),
616 &mut unavailable,
617 )
618 .is_empty()
619 );
620 assert_eq!(lookups, 2, "an outage must not be remembered as \"no layout\"");
621 }
622
623 #[test]
624 fn waiting_for_an_inflight_lookup_respects_the_timeout() {
625 let chain_id = next_chain_id();
626 let address = Address::with_last_byte(1);
627 let slot = {
628 let mut lookups = LOOKUPS.lock().unwrap();
629 lookups.entry((chain_id, address)).or_default().clone()
630 };
631 let _inflight = slot.lock().unwrap();
632 let started = Instant::now();
633 let mut fetched = false;
634
635 let result = resolve(chain_id, None, [address], Duration::from_millis(20), |_, _| {
636 fetched = true;
637 AddressMap::default()
638 });
639
640 assert!(result.is_empty());
641 assert!(!fetched);
642 assert!(started.elapsed() < Duration::from_secs(1));
643 }
644
645 #[cfg(unix)]
646 #[test]
647 fn solc_is_killed_when_compilation_times_out() {
648 let dir = tempfile::tempdir().unwrap();
649 let path = dir.path().join("solc");
650 std::fs::write(&path, "#!/bin/sh\nexec sleep 10\n").unwrap();
651 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
652 let solc = Solc {
653 solc: path,
654 version: NO_IMPORT_CALLBACK_SOLC,
655 base_path: None,
656 allow_paths: Default::default(),
657 include_paths: Default::default(),
658 extra_args: Vec::new(),
659 };
660 let input = SolcInput::new(SolcLanguage::Solidity, Default::default(), Default::default());
661 let started = Instant::now();
662
663 assert!(
664 run_solc(&solc, &NO_IMPORT_CALLBACK_SOLC, &input, Duration::from_millis(20)).is_err()
665 );
666 assert!(started.elapsed() < Duration::from_secs(1));
667 }
668
669 #[cfg(unix)]
670 #[test]
671 fn solc_runs_in_an_empty_sandbox_with_imports_disabled() {
672 let dir = tempfile::tempdir().unwrap();
673 let path = dir.path().join("solc");
674 std::fs::write(
675 &path,
676 "#!/bin/sh\ncase \" $* \" in *\" --no-import-callback \"*) ;; *) exit 1;; esac\n[ -z \"$(ls -A)\" ] || exit 1\nprintf '{\"contracts\":{}}'\n",
677 )
678 .unwrap();
679 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
680 let solc = Solc {
681 solc: path,
682 version: NO_IMPORT_CALLBACK_SOLC,
683 base_path: None,
684 allow_paths: Default::default(),
685 include_paths: Default::default(),
686 extra_args: Vec::new(),
687 };
688 let input = SolcInput::new(SolcLanguage::Solidity, Default::default(), Default::default());
689
690 assert!(run_solc(&solc, &NO_IMPORT_CALLBACK_SOLC, &input, Duration::from_secs(1)).is_ok());
691 }
692
693 fn source_less_metadata() -> Metadata {
694 Metadata {
695 source_code: SourceCodeMetadata::Sources(Default::default()),
696 abi: "[]".to_string(),
697 contract_name: "MissingSources".to_string(),
698 compiler_version: "v0.8.30".to_string(),
699 optimization_used: 0,
700 runs: 0,
701 constructor_arguments: Default::default(),
702 evm_version: String::new(),
703 library: String::new(),
704 license_type: String::new(),
705 proxy: 0,
706 implementation: None,
707 swarm_source: String::new(),
708 }
709 }
710
711 #[test]
712 fn retries_after_the_compilation_budget_expires() {
713 let chain_id = next_chain_id();
714 let address = Address::with_last_byte(1);
715 let metadata = Metadata {
716 source_code: SourceCodeMetadata::SourceCode(
717 "pragma solidity ^0.8.30; contract Counter { uint256 public count; }".to_string(),
718 ),
719 contract_name: "Counter".to_string(),
720 ..source_less_metadata()
721 };
722 let _compiler = COMPILER.lock().unwrap();
724 let result =
725 resolve(chain_id, None, [address], Duration::from_millis(20), |_, remaining| {
726 std::thread::sleep(remaining);
727 [(address, Some(metadata))].into_iter().collect()
728 });
729 assert!(result.is_empty());
730
731 let mut retried = false;
732 resolve(chain_id, None, [address], Duration::from_secs(1), |addresses, _| {
733 assert_eq!(addresses, [address]);
734 retried = true;
735 AddressMap::default()
736 });
737 assert!(retried, "a compilation timeout must not be memoized as no layout");
738 }
739
740 #[test]
741 fn source_less_metadata_is_rejected_without_compiling() {
742 let metadata = source_less_metadata();
743 assert!(compile_storage_layout(Address::ZERO, &metadata, Instant::now()).is_none());
744 }
745}