Repository: gakonst/ethers-rs
Pull request #1108 (2022-04-04 15:18:20)

feat(etherscan): add caching 
Supersedes #935 

onbjerg: This should work now
onbjerg: Actually, I should probably add an actual error for rate limits so we can catch it in Foundry for retries
onbjerg: This fails when the file doesn't exist, going back in draft

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 87007551..989cc1a3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1432,9 +1432,9 @@ dependencies = [
 
 [[package]]
 name = "eyre"
-version = "0.6.7"
+version = "0.6.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9289ed2c0440a6536e65119725cf91fc2c6b5e513bfd2e36e1134d7cca6ca12f"
+checksum = "4c2b6b5a29c02cdc822728b7d7b8ae1bab3e3b05d44522770ddd49722eeac7eb"
 dependencies = [
  "indenter",
  "once_cell",
diff --git a/Cargo.toml b/Cargo.toml
index 4761f195..d63af71e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -110,3 +110,44 @@ bytes = "1.1.0"
 [profile.release.package.ethers-wasm]
 # Tell `rustc` to optimize for small code size.
 opt-level = "s"
+
+
+[[example]]
+name = "contract_human_readable"
+path = "examples/contract_human_readable.rs"
+required-features = ["legacy"]
+
+[[example]]
+name = "contract_with_abi"
+path = "examples/contract_with_abi.rs"
+required-features = ["legacy"]
+
+[[example]]
+name = "contract_with_abi_and_bytecode"
+path = "examples/contract_with_abi_and_bytecode.rs"
+required-features = ["legacy"]
+
+[[example]]
+name = "ipc"
+path = "examples/ipc.rs"
+required-features = ["ipc"]
+
+[[example]]
+name = "ledger"
+path = "examples/ledger.rs"
+required-features = ["ledger"]
+
+[[example]]
+name = "moonbeam_with_abi"
+path = "examples/moonbeam_with_abi.rs"
+required-features = ["legacy"]
+
+[[example]]
+name = "trezor"
+path = "examples/trezor.rs"
+required-features = ["trezor"]
+
+[[example]]
+name = "yubi"
+path = "examples/yubi.rs"
+required-features = ["yubi"]
\ No newline at end of file
diff --git a/ethers-etherscan/src/contract.rs b/ethers-etherscan/src/contract.rs
index 29a63ea0..10195ecc 100644
--- a/ethers-etherscan/src/contract.rs
+++ b/ethers-etherscan/src/contract.rs
@@ -138,8 +138,8 @@ impl Default for CodeFormat {
 }
 
 #[derive(Debug, Serialize, Deserialize)]
+#[serde(transparent)]
 pub struct ContractMetadata {
-    #[serde(flatten)]
     pub items: Vec<Metadata>,
 }
 
@@ -284,12 +284,36 @@ impl Client {
     /// # }
     /// ```
     pub async fn contract_abi(&self, address: Address) -> Result<Abi> {
+        // apply caching
+        if let Some(ref cache) = self.cache {
+            // If this is None, then we have a cache miss
+            if let Some(src) = cache.get_abi(address) {
+                // If this is None, then the contract is not verified
+                return match src {
+                    Some(src) => Ok(src),
+                    None => Err(EtherscanError::ContractCodeNotVerified(address)),
+                }
+            }
+        }
+
         let query = self.create_query("contract", "getabi", HashMap::from([("address", address)]));
         let resp: Response<String> = self.get_json(&query).await?;
+        if resp.result.starts_with("Max rate limit reached") {
+            return Err(EtherscanError::RateLimitExceeded)
+        }
         if resp.result.starts_with("Contract source code not verified") {
+            if let Some(ref cache) = self.cache {
+                let _ = cache.set_abi(address, None);
+            }
             return Err(EtherscanError::ContractCodeNotVerified(address))
         }
-        Ok(serde_json::from_str(&resp.result)?)
+        let abi = serde_json::from_str(&resp.result)?;
+
+        if let Some(ref cache) = self.cache {
+            let _ = cache.set_abi(address, Some(&abi));
+        }
+
+        Ok(abi)
     }
 
     /// Get Contract Source Code for Verified Contract Source Codes
@@ -307,13 +331,34 @@ impl Client {
     /// # }
     /// ```
     pub async fn contract_source_code(&self, address: Address) -> Result<ContractMetadata> {
+        // apply caching
+        if let Some(ref cache) = self.cache {
+            // If this is None, then we have a cache miss
+            if let Some(src) = cache.get_source(address) {
+                // If this is None, then the contract is not verified
+                return match src {
+                    Some(src) => Ok(src),
+                    None => Err(EtherscanError::ContractCodeNotVerified(address)),
+                }
+            }
+        }
+
         let query =
             self.create_query("contract", "getsourcecode", HashMap::from([("address", address)]));
         let response: Response<Vec<Metadata>> = self.get_json(&query).await?;
         if response.result.iter().any(|item| item.abi == "Contract source code not verified") {
+            if let Some(ref cache) = self.cache {
+                let _ = cache.set_source(address, None);
+            }
             return Err(EtherscanError::ContractCodeNotVerified(address))
         }
-        Ok(ContractMetadata { items: response.result })
+        let res = ContractMetadata { items: response.result };
+
+        if let Some(ref cache) = self.cache {
+            let _ = cache.set_source(address, Some(&res));
+        }
+
+        Ok(res)
     }
 }
 
diff --git a/ethers-etherscan/src/errors.rs b/ethers-etherscan/src/errors.rs
index cad7a808..d70f632c 100644
--- a/ethers-etherscan/src/errors.rs
+++ b/ethers-etherscan/src/errors.rs
@@ -3,17 +3,17 @@ use std::env::VarError;
 
 #[derive(Debug, thiserror::Error)]
 pub enum EtherscanError {
-    #[error("chain {0} not supported")]
+    #[error("Chain {0} not supported")]
     ChainNotSupported(Chain),
-    #[error("contract execution call failed: {0}")]
+    #[error("Contract execution call failed: {0}")]
     ExecutionFailed(String),
-    #[error("balance failed")]
+    #[error("Balance failed")]
     BalanceFailed,
-    #[error("tx receipt failed")]
+    #[error("Transaction receipt failed")]
     TransactionReceiptFailed,
-    #[error("gas estimation failed")]
+    #[error("Gas estimation failed")]
     GasEstimationFailed,
-    #[error("bad status code {0}")]
+    #[error("Bad status code: {0}")]
     BadStatusCode(String),
     #[error(transparent)]
     EnvVarNotFound(#[from] VarError),
@@ -23,8 +23,12 @@ pub enum EtherscanError {
     Serde(#[from] serde_json::Error),
     #[error("Contract source code not verified: {0}")]
     ContractCodeNotVerified(Address),
+    #[error("Rate limit exceeded")]
+    RateLimitExceeded,
     #[error(transparent)]
     IO(#[from] std::io::Error),
     #[error("Local networks (e.g. ganache, geth --dev) cannot be indexed by etherscan")]
     LocalNetworksNotSupported,
+    #[error("Unknown error: {0}")]
+    Unknown(String),
 }
diff --git a/ethers-etherscan/src/lib.rs b/ethers-etherscan/src/lib.rs
index 2255ac00..fea67802 100644
--- a/ethers-etherscan/src/lib.rs
+++ b/ethers-etherscan/src/lib.rs
@@ -1,12 +1,21 @@
 //! Bindings for [etherscan.io web api](https://docs.etherscan.io/)
 
-use std::borrow::Cow;
-
+use std::{
+    borrow::Cow,
+    io::Write,
+    path::PathBuf,
+    time::{Duration, SystemTime, UNIX_EPOCH},
+};
+
+use contract::ContractMetadata;
 use reqwest::{header, Url};
 use serde::{de::DeserializeOwned, Deserialize, Serialize};
 
 use errors::EtherscanError;
-use ethers_core::{abi::Address, types::Chain};
+use ethers_core::{
+    abi::{Abi, Address},
+    types::Chain,
+};
 
 pub mod account;
 pub mod contract;
@@ -28,9 +37,92 @@ pub struct Client {
     etherscan_api_url: Url,
     /// Etherscan base endpoint like <https://etherscan.io>
     etherscan_url: Url,
+    /// Path to where ABI files should be cached
+    cache: Option<Cache>,
+}
+
+/// A wrapper around an Etherscan cache object with an expiry
+#[derive(Clone, Debug, Deserialize, Serialize)]
+struct CacheEnvelope<T> {
+    expiry: u64,
+    data: T,
+}
+
+/// Simple cache for etherscan requests
+#[derive(Clone, Debug)]
+struct Cache {
+    root: PathBuf,
+    ttl: Duration,
+}
+
+impl Cache {
+    fn new(root: PathBuf, ttl: Duration) -> Self {
+        Self { root, ttl }
+    }
+
+    fn get_abi(&self, address: Address) -> Option<Option<ethers_core::abi::Abi>> {
+        self.get("abi", address)
+    }
+
+    fn set_abi(&self, address: Address, abi: Option<&Abi>) {
+        self.set("abi", address, abi)
+    }
+
+    fn get_source(&self, address: Address) -> Option<Option<ContractMetadata>> {
+        self.get("sources", address)
+    }
+
+    fn set_source(&self, address: Address, source: Option<&ContractMetadata>) {
+        self.set("sources", address, source)
+    }
+
+    fn set<T: Serialize>(&self, prefix: &str, address: Address, item: T) {
+        let path = self.root.join(prefix).join(format!("{:?}.json", address));
+        let writer = std::fs::File::create(path).ok().map(std::io::BufWriter::new);
+        if let Some(mut writer) = writer {
+            let _ = serde_json::to_writer(
+                &mut writer,
+                &CacheEnvelope {
+                    expiry: SystemTime::now()
+                        .checked_add(self.ttl)
+                        .expect("cache ttl overflowed")
+                        .duration_since(UNIX_EPOCH)
+                        .expect("system time is before unix epoch")
+                        .as_secs(),
+                    data: item,
+                },
+            );
+            let _ = writer.flush();
+        }
+    }
+
+    fn get<T: DeserializeOwned>(&self, prefix: &str, address: Address) -> Option<T> {
+        let path = self.root.join(prefix).join(format!("{:?}.json", address));
+        let reader = std::io::BufReader::new(std::fs::File::open(path).ok()?);
+        if let Ok(inner) = serde_json::from_reader::<_, CacheEnvelope<T>>(reader) {
+            // If this does not return None then we have passed the expiry
+            if SystemTime::now().checked_sub(Duration::from_secs(inner.expiry)).is_some() {
+                return None
+            }
+
+            return Some(inner.data)
+        }
+        None
+    }
 }
 
 impl Client {
+    pub fn new_cached(
+        chain: Chain,
+        api_key: impl Into<String>,
+        cache_root: Option<PathBuf>,
+        cache_ttl: Duration,
+    ) -> Result<Self> {
+        let mut this = Self::new(chain, api_key)?;
+        this.cache = cache_root.map(|root| Cache::new(root, cache_ttl));
+        Ok(this)
+    }
+
     /// Create a new client with the correct endpoints based on the chain and provided API key
     pub fn new(chain: Chain, api_key: impl Into<String>) -> Result<Self> {
         let (etherscan_api_url, etherscan_url) = match chain {
@@ -101,6 +193,7 @@ impl Client {
             api_key: api_key.into(),
             etherscan_api_url: etherscan_api_url.expect("is valid http"),
             etherscan_url: etherscan_url.expect("is valid http"),
+            cache: None,
         })
     }
 
@@ -180,7 +273,7 @@ impl Client {
 
     /// Execute an API GET request with parameters
     async fn get_json<T: DeserializeOwned, Q: Serialize>(&self, query: &Q) -> Result<Response<T>> {
-        Ok(self
+        let res: ResponseData<T> = self
             .client
             .get(self.etherscan_api_url.clone())
             .header(header::ACCEPT, "application/json")
@@ -188,7 +281,18 @@ impl Client {
             .send()
             .await?
             .json()
-            .await?)
+            .await?;
+
+        match res {
+            ResponseData::Error { result, .. } => {
+                if result.starts_with("Max rate limit reached") {
+                    Err(EtherscanError::RateLimitExceeded)
+                } else {
+                    Err(EtherscanError::Unknown(result))
+                }
+            }
+            ResponseData::Success(res) => Ok(res),
+        }
     }
 
     fn create_query<T: Serialize>(
@@ -214,6 +318,13 @@ pub struct Response<T> {
     pub result: T,
 }
 
+#[derive(Deserialize, Debug, Clone)]
+#[serde(untagged)]
+pub enum ResponseData<T> {
+    Success(Response<T>),
+    Error { status: String, message: String, result: String },
+}
+
 /// The type that gets serialized as query
 #[derive(Debug, Serialize)]
 struct Query<'a, T: Serialize> {
@@ -240,7 +351,7 @@ mod tests {
         let err = Client::new_from_env(Chain::XDai).unwrap_err();
 
         assert!(matches!(err, EtherscanError::ChainNotSupported(_)));
-        assert_eq!(err.to_string(), "chain xdai not supported");
+        assert_eq!(err.to_string(), "Chain xdai not supported");
     }
 
     #[test]
diff --git a/ethers-etherscan/src/transaction.rs b/ethers-etherscan/src/transaction.rs
index 694aaebd..e916d55a 100644
--- a/ethers-etherscan/src/transaction.rs
+++ b/ethers-etherscan/src/transaction.rs
@@ -91,7 +91,7 @@ mod tests {
                 .unwrap_err();
 
             assert!(matches!(err, EtherscanError::ExecutionFailed(_)));
-            assert_eq!(err.to_string(), "contract execution call failed: Bad jump destination");
+            assert_eq!(err.to_string(), "Contract execution call failed: Bad jump destination");
         })
         .await
     }
diff --git a/ethers-solc/src/artifacts/mod.rs b/ethers-solc/src/artifacts/mod.rs
index 85f21b4b..9ded58dc 100644
--- a/ethers-solc/src/artifacts/mod.rs
+++ b/ethers-solc/src/artifacts/mod.rs
@@ -3,7 +3,7 @@ use ethers_core::abi::Abi;
 
 use colored::Colorize;
 use md5::Digest;
-use semver::Version;
+use semver::{Version, VersionReq};
 use std::{
     collections::{BTreeMap, HashSet},
     fmt, fs,
@@ -92,6 +92,22 @@ impl CompilerInput {
         res
     }
 
+    /// This will remove/adjust values in the `CompilerInput` that are not compatible with this
+    /// version
+    pub fn sanitized(mut self, version: &Version) -> Self {
+        static PRE_V0_6_0: once_cell::sync::Lazy<VersionReq> =
+            once_cell::sync::Lazy::new(|| VersionReq::parse("<0.6.0").unwrap());
+
+        if PRE_V0_6_0.matches(version) {
+            if let Some(ref mut meta) = self.settings.metadata {
+                // introduced in <https://docs.soliditylang.org/en/v0.6.0/using-the-compiler.html#compiler-api>
+                // missing in <https://docs.soliditylang.org/en/v0.5.17/using-the-compiler.html#compiler-api>
+                meta.bytecode_hash.take();
+            }
+        }
+        self
+    }
+
     /// Sets the settings for compilation
     #[must_use]
     pub fn settings(mut self, settings: Settings) -> Self {
@@ -1400,4 +1416,24 @@ mod tests {
             )
         }
     }
+
+    #[test]
+    fn can_sanitize_byte_code_hash() {
+        let version: Version = "0.6.0".parse().unwrap();
+
+        let settings = Settings { metadata: Some(BytecodeHash::Ipfs.into()), ..Default::default() };
+
+        let input = CompilerInput {
+            language: "Solidity".to_string(),
+            sources: Default::default(),
+            settings,
+        };
+
+        let i = input.clone().sanitized(&version);
+        assert_eq!(i.settings.metadata.unwrap().bytecode_hash, Some(BytecodeHash::Ipfs));
+
+        let version: Version = "0.5.17".parse().unwrap();
+        let i = input.sanitized(&version);
+        assert!(i.settings.metadata.unwrap().bytecode_hash.is_none());
+    }
 }
diff --git a/ethers-solc/src/cache.rs b/ethers-solc/src/cache.rs
index e1f2ed58..f87c4bd4 100644
--- a/ethers-solc/src/cache.rs
+++ b/ethers-solc/src/cache.rs
@@ -807,6 +807,14 @@ impl<'a, T: ArtifactOutput> ArtifactsCache<'a, T> {
         Ok(cache)
     }
 
+    /// Returns the graph data for this project
+    pub fn graph(&self) -> &GraphEdges {
+        match self {
+            ArtifactsCache::Ephemeral(graph, _) => graph,
+            ArtifactsCache::Cached(inner) => &inner.edges,
+        }
+    }
+
     #[cfg(test)]
     #[allow(unused)]
     #[doc(hidden)]
diff --git a/ethers-solc/src/compile/project.rs b/ethers-solc/src/compile/project.rs
index 7169582c..c831afd3 100644
--- a/ethers-solc/src/compile/project.rs
+++ b/ethers-solc/src/compile/project.rs
@@ -114,7 +114,7 @@ use crate::{
 };
 use rayon::prelude::*;
 
-use crate::filter::SparseOutputFileFilter;
+use crate::filter::SparseOutputFilter;
 use std::{collections::btree_map::BTreeMap, path::PathBuf, time::Instant};
 
 #[derive(Debug)]
@@ -125,7 +125,7 @@ pub struct ProjectCompiler<'a, T: ArtifactOutput> {
     /// how to compile all the sources
     sources: CompilerSources,
     /// How to select solc [`crate::artifacts::CompilerOutput`] for files
-    sparse_output: SparseOutputFileFilter,
+    sparse_output: SparseOutputFilter,
 }
 
 impl<'a, T: ArtifactOutput> ProjectCompiler<'a, T> {
@@ -184,7 +184,7 @@ impl<'a, T: ArtifactOutput> ProjectCompiler<'a, T> {
 
     /// Applies the specified filter to be applied when selecting solc output for
     /// specific files to be compiled
-    pub fn with_sparse_output(mut self, sparse_output: impl Into<SparseOutputFileFilter>) -> Self {
+    pub fn with_sparse_output(mut self, sparse_output: impl Into<SparseOutputFilter>) -> Self {
         self.sparse_output = sparse_output.into();
         self
     }
@@ -232,7 +232,7 @@ struct PreprocessedState<'a, T: ArtifactOutput> {
     sources: FilteredCompilerSources,
     /// cache that holds [CacheEntry] object if caching is enabled and the project is recompiled
     cache: ArtifactsCache<'a, T>,
-    sparse_output: SparseOutputFileFilter,
+    sparse_output: SparseOutputFilter,
 }
 
 impl<'a, T: ArtifactOutput> PreprocessedState<'a, T> {
@@ -243,6 +243,7 @@ impl<'a, T: ArtifactOutput> PreprocessedState<'a, T> {
             &cache.project().solc_config.settings,
             &cache.project().paths,
             sparse_output,
+            cache.graph(),
         )?;
 
         Ok(CompiledState { output, cache })
@@ -372,14 +373,15 @@ impl FilteredCompilerSources {
         self,
         settings: &Settings,
         paths: &ProjectPathsConfig,
-        sparse_output: SparseOutputFileFilter,
+        sparse_output: SparseOutputFilter,
+        graph: &GraphEdges,
     ) -> Result<AggregatedCompilerOutput> {
         match self {
             FilteredCompilerSources::Sequential(input) => {
-                compile_sequential(input, settings, paths, sparse_output)
+                compile_sequential(input, settings, paths, sparse_output, graph)
             }
             FilteredCompilerSources::Parallel(input, j) => {
-                compile_parallel(input, j, settings, paths, sparse_output)
+                compile_parallel(input, j, settings, paths, sparse_output, graph)
             }
         }
     }
@@ -399,7 +401,8 @@ fn compile_sequential(
     input: VersionedFilteredSources,
     settings: &Settings,
     paths: &ProjectPathsConfig,
-    sparse_output: SparseOutputFileFilter,
+    sparse_output: SparseOutputFilter,
+    graph: &GraphEdges,
 ) -> Result<AggregatedCompilerOutput> {
     let mut aggregated = AggregatedCompilerOutput::default();
     tracing::trace!("compiling {} jobs sequentially", input.len());
@@ -425,7 +428,7 @@ fn compile_sequential(
         // depending on the composition of the filtered sources, the output selection can be
         // optimized
         let mut opt_settings = settings.clone();
-        let sources = sparse_output.sparse_sources(filtered_sources, &mut opt_settings);
+        let sources = sparse_output.sparse_sources(filtered_sources, &mut opt_settings, graph);
 
         for input in CompilerInput::with_sources(sources) {
             let actually_dirty = input
@@ -448,7 +451,8 @@ fn compile_sequential(
             let input = input
                 .settings(opt_settings.clone())
                 .normalize_evm_version(&version)
-                .with_remappings(paths.remappings.clone());
+                .with_remappings(paths.remappings.clone())
+                .sanitized(&version);
 
             tracing::trace!(
                 "calling solc `{}` with {} sources {:?}",
@@ -475,7 +479,8 @@ fn compile_parallel(
     num_jobs: usize,
     settings: &Settings,
     paths: &ProjectPathsConfig,
-    sparse_output: SparseOutputFileFilter,
+    sparse_output: SparseOutputFilter,
+    graph: &GraphEdges,
 ) -> Result<AggregatedCompilerOutput> {
     debug_assert!(num_jobs > 1);
     tracing::trace!(
@@ -501,7 +506,7 @@ fn compile_parallel(
         // depending on the composition of the filtered sources, the output selection can be
         // optimized
         let mut opt_settings = settings.clone();
-        let sources = sparse_output.sparse_sources(filtered_sources, &mut opt_settings);
+        let sources = sparse_output.sparse_sources(filtered_sources, &mut opt_settings, graph);
 
         for input in CompilerInput::with_sources(sources) {
             let actually_dirty = input
@@ -525,7 +530,8 @@ fn compile_parallel(
             let job = input
                 .settings(settings.clone())
                 .normalize_evm_version(&version)
-                .with_remappings(paths.remappings.clone());
+                .with_remappings(paths.remappings.clone())
+                .sanitized(&version);
 
             jobs.push((solc.clone(), version.clone(), job, actually_dirty))
         }
diff --git a/ethers-solc/src/filter.rs b/ethers-solc/src/filter.rs
index ad6ee238..0e1d2056 100644
--- a/ethers-solc/src/filter.rs
+++ b/ethers-solc/src/filter.rs
@@ -2,6 +2,7 @@
 
 use crate::{
     artifacts::{output_selection::OutputSelection, Settings},
+    resolver::GraphEdges,
     Source, Sources,
 };
 use std::{
@@ -52,7 +53,7 @@ impl FileFilter for TestFileFilter {
 
 /// A type that can apply a filter to a set of preprocessed [FilteredSources] in order to set sparse
 /// output for specific files
-pub enum SparseOutputFileFilter {
+pub enum SparseOutputFilter {
     /// Sets the configured [OutputSelection] for dirty files only.
     ///
     /// In other words, we request the output of solc only for files that have been detected as
@@ -62,79 +63,130 @@ pub enum SparseOutputFileFilter {
     Custom(Box<dyn FileFilter>),
 }
 
-impl SparseOutputFileFilter {
+impl SparseOutputFilter {
     /// While solc needs all the files to compile the actual _dirty_ files, we can tell solc to
     /// output everything for those dirty files as currently configured in the settings, but output
     /// nothing for the other files that are _not_ dirty.
     ///
     /// This will modify the [OutputSelection] of the [Settings] so that we explicitly select the
     /// files' output based on their state.
-    pub fn sparse_sources(&self, sources: FilteredSources, settings: &mut Settings) -> Sources {
-        fn apply(
-            sources: &FilteredSources,
-            settings: &mut Settings,
-            f: impl Fn(&PathBuf, &FilteredSource) -> bool,
-        ) {
-            let selection = settings
-                .output_selection
-                .as_mut()
-                .remove("*")
-                .unwrap_or_else(OutputSelection::default_file_output_selection);
-
-            for (file, source) in sources.0.iter() {
-                if f(file, source) {
+    ///
+    /// This also takes the project's graph as input, this allows us to check if the files the
+    /// filter matches depend on libraries that need to be linked
+    pub fn sparse_sources(
+        &self,
+        sources: FilteredSources,
+        settings: &mut Settings,
+        graph: &GraphEdges,
+    ) -> Sources {
+        match self {
+            SparseOutputFilter::AllDirty => {
+                if !sources.all_dirty() {
+                    Self::all_dirty(&sources, settings)
+                }
+            }
+            SparseOutputFilter::Custom(f) => {
+                Self::apply_custom_filter(&sources, settings, graph, f)
+            }
+        };
+        sources.into()
+    }
+
+    /// applies a custom filter and prunes the output of those source files for which the filter
+    /// returns `false`.
+    ///
+    /// However, this could in accidentally pruning required link references (imported libraries)
+    /// that will be required at runtime. For example if the filter only matches test files
+    /// `*.t.sol` files and a test file makes use of a library that won't be inlined, then the
+    /// libraries bytecode will be missing. Therefore, we detect all linkReferences of a file
+    /// and treat them as if the filter would also apply to those.
+    #[allow(clippy::borrowed_box)]
+    fn apply_custom_filter(
+        sources: &FilteredSources,
+        settings: &mut Settings,
+        graph: &GraphEdges,
+        f: &Box<dyn FileFilter>,
+    ) {
+        tracing::trace!("optimizing output selection with custom filter",);
+        let selection = settings
+            .output_selection
+            .as_mut()
+            .remove("*")
+            .unwrap_or_else(OutputSelection::default_file_output_selection);
+
+        for (file, source) in sources.0.iter() {
+            let key = format!("{}", file.display());
+            if source.is_dirty() && f.is_match(file) {
+                settings.output_selection.as_mut().insert(key, selection.clone());
+
+                // the filter might not cover link references that will be required by the file, so
+                // we check if the file has any libraries that won't be inlined and include them as
+                // well
+                for link in graph.get_link_references(file) {
                     settings
                         .output_selection
                         .as_mut()
-                        .insert(format!("{}", file.display()), selection.clone());
-                } else {
-                    tracing::trace!("using pruned output selection for {}", file.display());
-                    settings.output_selection.as_mut().insert(
-                        format!("{}", file.display()),
-                        OutputSelection::empty_file_output_select(),
-                    );
+                        .insert(format!("{}", link.display()), selection.clone());
                 }
+            } else if !settings.output_selection.as_ref().contains_key(&key) {
+                tracing::trace!("using pruned output selection for {}", file.display());
+                settings
+                    .output_selection
+                    .as_mut()
+                    .insert(key, OutputSelection::empty_file_output_select());
             }
         }
+    }
 
-        match self {
-            SparseOutputFileFilter::AllDirty => {
-                if !sources.all_dirty() {
-                    // settings can be optimized
-                    tracing::trace!(
-                        "optimizing output selection for {}/{} sources",
-                        sources.clean().count(),
-                        sources.len()
-                    );
-                    apply(&sources, settings, |_, source| source.is_dirty())
-                }
-            }
-            SparseOutputFileFilter::Custom(f) => {
-                tracing::trace!("optimizing output selection with custom filter",);
-                apply(&sources, settings, |p, source| source.is_dirty() && f.is_match(p));
+    /// prunes all clean sources and only selects an output for dirty sources
+    fn all_dirty(sources: &FilteredSources, settings: &mut Settings) {
+        // settings can be optimized
+        tracing::trace!(
+            "optimizing output selection for {}/{} sources",
+            sources.clean().count(),
+            sources.len()
+        );
+
+        let selection = settings
+            .output_selection
+            .as_mut()
+            .remove("*")
+            .unwrap_or_else(OutputSelection::default_file_output_selection);
+
+        for (file, source) in sources.0.iter() {
+            if source.is_dirty() {
+                settings
+                    .output_selection
+                    .as_mut()
+                    .insert(format!("{}", file.display()), selection.clone());
+            } else {
+                tracing::trace!("using pruned output selection for {}", file.display());
+                settings.output_selection.as_mut().insert(
+                    format!("{}", file.display()),
+                    OutputSelection::empty_file_output_select(),
+                );
             }
-        };
-        sources.into()
+        }
     }
 }
 
-impl From<Box<dyn FileFilter>> for SparseOutputFileFilter {
+impl From<Box<dyn FileFilter>> for SparseOutputFilter {
     fn from(f: Box<dyn FileFilter>) -> Self {
-        SparseOutputFileFilter::Custom(f)
+        SparseOutputFilter::Custom(f)
     }
 }
 
-impl Default for SparseOutputFileFilter {
+impl Default for SparseOutputFilter {
     fn default() -> Self {
-        SparseOutputFileFilter::AllDirty
+        SparseOutputFilter::AllDirty
     }
 }
 
-impl fmt::Debug for SparseOutputFileFilter {
+impl fmt::Debug for SparseOutputFilter {
     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
         match self {
-            SparseOutputFileFilter::AllDirty => f.write_str("AllDirty"),
-            SparseOutputFileFilter::Custom(_) => f.write_str("Custom"),
+            SparseOutputFilter::AllDirty => f.write_str("AllDirty"),
+            SparseOutputFilter::Custom(_) => f.write_str("Custom"),
         }
     }
 }
diff --git a/ethers-solc/src/project_util/mod.rs b/ethers-solc/src/project_util/mod.rs
index a35a41aa..a415c96a 100644
--- a/ethers-solc/src/project_util/mod.rs
+++ b/ethers-solc/src/project_util/mod.rs
@@ -7,7 +7,8 @@ use crate::{
     project_util::mock::{MockProjectGenerator, MockProjectSettings},
     utils::tempdir,
     Artifact, ArtifactOutput, Artifacts, ConfigurableArtifacts, ConfigurableContractArtifact,
-    PathStyle, Project, ProjectCompileOutput, ProjectPathsConfig, SolFilesCache, SolcIoError,
+    FileFilter, PathStyle, Project, ProjectCompileOutput, ProjectPathsConfig, SolFilesCache,
+    SolcIoError,
 };
 use fs_extra::{dir, file};
 use std::{
@@ -69,6 +70,13 @@ impl<T: ArtifactOutput> TempProject<T> {
         self.project().compile()
     }
 
+    pub fn compile_sparse<F: FileFilter + 'static>(
+        &self,
+        filter: F,
+    ) -> Result<ProjectCompileOutput<T>> {
+        self.project().compile_sparse(filter)
+    }
+
     pub fn flatten(&self, target: &Path) -> Result<String> {
         self.project().flatten(target)
     }
diff --git a/ethers-solc/src/resolver/mod.rs b/ethers-solc/src/resolver/mod.rs
index e5d1cbae..bb947ae2 100644
--- a/ethers-solc/src/resolver/mod.rs
+++ b/ethers-solc/src/resolver/mod.rs
@@ -52,21 +52,23 @@ use std::{
     path::{Path, PathBuf},
 };
 
+use parse::{SolData, SolDataUnit};
 use rayon::prelude::*;
-use regex::Match;
+
 use semver::VersionReq;
-use solang_parser::pt::{Import, Loc, SourceUnitPart};
 
-use crate::{error::Result, utils, ProjectPathsConfig, Solc, SolcError, Source, Sources};
+use crate::{error::Result, utils, ProjectPathsConfig, SolcError, Source, Sources};
 
+mod parse;
 mod tree;
+
 pub use tree::{print, Charset, TreeOptions};
 
 /// The underlying edges of the graph which only contains the raw relationship data.
 ///
 /// This is kept separate from the `Graph` as the `Node`s get consumed when the `Solc` to `Sources`
 /// set is determined.
-#[derive(Debug, Clone)]
+#[derive(Debug)]
 pub struct GraphEdges {
     /// The indices of `edges` correspond to the `nodes`. That is, `edges[0]`
     /// is the set of outgoing edges for `nodes[0]`.
@@ -77,6 +79,8 @@ pub struct GraphEdges {
     rev_indices: HashMap<usize, PathBuf>,
     /// the identified version requirement of a file
     versions: HashMap<usize, Option<VersionReq>>,
+    /// the extracted data from the source file
+    data: HashMap<usize, SolData>,
     /// with how many input files we started with, corresponds to `let input_files =
     /// nodes[..num_input_files]`.
     ///
@@ -152,6 +156,22 @@ impl GraphEdges {
             .and_then(|idx| self.versions.get(idx))
             .and_then(|v| v.as_ref())
     }
+
+    /// Returns those library files that will be required as `linkReferences` by the given file
+    ///
+    /// This is a preprocess function that attempts to resolve those libraries that will the
+    /// solidity `file` will be required to link. And further restrict this list to libraries
+    /// that won't be inlined See also [SolLibrary](parse::SolLibrary)
+    pub fn get_link_references(&self, file: impl AsRef<Path>) -> HashSet<&PathBuf> {
+        let mut link_references = HashSet::new();
+        for import in self.all_imported_nodes(self.node_id(file)) {
+            let data = &self.data[&import];
+            if data.has_link_references() {
+                link_references.insert(&self.rev_indices[&import]);
+            }
+        }
+        link_references
+    }
 }
 
 /// Represents a fully-resolved solidity dependency graph. Each node in the graph
@@ -159,6 +179,7 @@ impl GraphEdges {
 /// See also <https://docs.soliditylang.org/en/latest/layout-of-source-files.html?highlight=import#importing-other-source-files>
 #[derive(Debug)]
 pub struct Graph {
+    /// all nodes in the project, a `Node` represents a single file
     nodes: Vec<Node>,
     /// relationship of the nodes
     edges: GraphEdges,
@@ -222,11 +243,25 @@ impl Graph {
         self.node_ids(start).map(move |idx| self.node(idx))
     }
 
+    fn split(self) -> (Vec<(PathBuf, Source)>, GraphEdges) {
+        let Graph { nodes, mut edges, .. } = self;
+        // need to move the extracted data to the edges, essentially splitting the node so we have
+        // access to the data at a later stage in the compile pipeline
+        let mut sources = Vec::new();
+        for (idx, node) in nodes.into_iter().enumerate() {
+            let Node { path, source, data } = node;
+            sources.push((path, source));
+            edges.data.insert(idx, data);
+        }
+
+        (sources, edges)
+    }
+
     /// Consumes the `Graph`, effectively splitting the `nodes` and the `GraphEdges` off and
     /// returning the `nodes` converted to `Sources`
     pub fn into_sources(self) -> (Sources, GraphEdges) {
-        let Graph { nodes, edges, .. } = self;
-        (nodes.into_iter().map(|node| (node.path, node.source)).collect(), edges)
+        let (sources, edges) = self.split();
+        (sources.into_iter().collect(), edges)
     }
 
     /// Returns an iterator that yields only those nodes that represent input files.
@@ -255,7 +290,7 @@ impl Graph {
                 resolved_imports.push(idx);
             } else {
                 // imported file is not part of the input files
-                let node = read_node(&target)?;
+                let node = Node::read(&target)?;
                 unresolved.push_back((target.clone(), node));
                 let idx = index.len();
                 index.insert(target, idx);
@@ -269,7 +304,7 @@ impl Graph {
         let mut unresolved: VecDeque<(PathBuf, Node)> = sources
             .into_par_iter()
             .map(|(path, source)| {
-                let data = parse_data(source.as_ref(), &path);
+                let data = SolData::parse(source.as_ref(), &path);
                 (path.clone(), Node { path, source, data })
             })
             .collect();
@@ -317,6 +352,7 @@ impl Graph {
                 .enumerate()
                 .map(|(idx, node)| (idx, node.data.version_req.clone()))
                 .collect(),
+            data: Default::default(),
         };
         Ok(Graph { nodes, edges, root: paths.root.clone() })
     }
@@ -344,7 +380,7 @@ impl Graph {
         /// cache entry for them as well. This can be optimized however
         fn insert_imports(
             idx: usize,
-            all_nodes: &mut HashMap<usize, Node>,
+            all_nodes: &mut HashMap<usize, (PathBuf, Source)>,
             sources: &mut Sources,
             edges: &[Vec<usize>],
             num_input_files: usize,
@@ -354,8 +390,8 @@ impl Graph {
                 // nodes are handled separately
                 if dep >= num_input_files {
                     // library import
-                    if let Some(node) = all_nodes.remove(&dep) {
-                        sources.insert(node.path, node.source);
+                    if let Some((path, source)) = all_nodes.remove(&dep) {
+                        sources.insert(path, source);
                         insert_imports(dep, all_nodes, sources, edges, num_input_files);
                     }
                 }
@@ -363,8 +399,10 @@ impl Graph {
         }
 
         let versioned_nodes = self.get_input_node_versions(offline)?;
-        let Self { nodes, edges, .. } = self;
+        let (nodes, edges) = self.split();
+
         let mut versioned_sources = HashMap::with_capacity(versioned_nodes.len());
+
         let mut all_nodes = nodes.into_iter().enumerate().collect::<HashMap<_, _>>();
 
         // determine the `Sources` set for each solc version
@@ -373,8 +411,8 @@ impl Graph {
             // we only process input nodes (from sources, tests for example)
             for idx in input_node_indices {
                 // insert the input node in the sources set and remove it from the available set
-                let node = all_nodes.remove(&idx).expect("node is preset. qed");
-                sources.insert(node.path, node.source);
+                let (path, source) = all_nodes.remove(&idx).expect("node is preset. qed");
+                sources.insert(path, source);
                 insert_imports(
                     idx,
                     &mut all_nodes,
@@ -444,6 +482,8 @@ impl Graph {
         &self,
         offline: bool,
     ) -> Result<HashMap<crate::SolcVersion, Vec<usize>>> {
+        use crate::Solc;
+
         tracing::trace!("resolving input node versions");
         // this is likely called by an application and will be eventually printed so we don't exit
         // on first error, instead gather all the errors and return a bundled error message instead
@@ -645,7 +685,9 @@ impl VersionedSources {
     pub fn get(
         self,
         allowed_lib_paths: &crate::AllowedLibPaths,
-    ) -> Result<std::collections::BTreeMap<Solc, (semver::Version, Sources)>> {
+    ) -> Result<std::collections::BTreeMap<crate::Solc, (semver::Version, Sources)>> {
+        use crate::Solc;
+
         // we take the installer lock here to ensure installation checking is done in sync
         #[cfg(any(test, feature = "tests"))]
         let _lock = crate::compile::take_solc_installer_lock();
@@ -692,12 +734,23 @@ impl VersionedSources {
 
 #[derive(Debug)]
 pub struct Node {
+    /// path of the solidity  file
     path: PathBuf,
+    /// content of the solidity file
     source: Source,
+    /// parsed data
     data: SolData,
 }
 
 impl Node {
+    /// Reads the content of the file and returns a [Node] containing relevant information
+    pub fn read(file: impl AsRef<Path>) -> crate::Result<Self> {
+        let file = file.as_ref();
+        let source = Source::read(file).map_err(SolcError::Resolve)?;
+        let data = SolData::parse(source.as_ref(), file);
+        Ok(Self { path: file.to_path_buf(), source, data })
+    }
+
     pub fn content(&self) -> &str {
         &self.source.content
     }
@@ -732,199 +785,10 @@ impl<'a> fmt::Display for DisplayNode<'a> {
     }
 }
 
-#[derive(Debug, Clone)]
-#[allow(unused)]
-struct SolData {
-    license: Option<SolDataUnit<String>>,
-    version: Option<SolDataUnit<String>>,
-    imports: Vec<SolDataUnit<PathBuf>>,
-    version_req: Option<VersionReq>,
-}
-
-impl SolData {
-    #[allow(unused)]
-    fn fmt_version<W: std::fmt::Write>(
-        &self,
-        f: &mut W,
-    ) -> std::result::Result<(), std::fmt::Error> {
-        if let Some(ref version) = self.version {
-            write!(f, "({})", version.data)?;
-        }
-        Ok(())
-    }
-}
-
-#[derive(Debug, Clone)]
-pub struct SolDataUnit<T> {
-    loc: Location,
-    data: T,
-}
-#[derive(Debug, Clone)]
-pub struct Location {
-    pub start: usize,
-    pub end: usize,
-}
-
-/// Solidity Data Unit decorated with its location within the file
-impl<T> SolDataUnit<T> {
-    pub fn new(data: T, loc: Location) -> Self {
-        Self { data, loc }
-    }
-
-    /// Returns the underlying data for the unit
-    pub fn data(&self) -> &T {
-        &self.data
-    }
-
-    /// Returns the location of the given data unit
-    pub fn loc(&self) -> (usize, usize) {
-        (self.loc.start, self.loc.end)
-    }
-
-    /// Returns the location of the given data unit adjusted by an offset.
-    /// Used to determine new position of the unit within the file after
-    /// content manipulation.
-    pub fn loc_by_offset(&self, offset: isize) -> (usize, usize) {
-        (
-            offset.saturating_add(self.loc.start as isize) as usize,
-            // make the end location exclusive
-            offset.saturating_add(self.loc.end as isize + 1) as usize,
-        )
-    }
-}
-
-impl From<Match<'_>> for Location {
-    fn from(src: Match) -> Self {
-        Location { start: src.start(), end: src.end() }
-    }
-}
-
-impl From<Loc> for Location {
-    fn from(src: Loc) -> Self {
-        match src {
-            Loc::File(_, start, end) => Location { start, end },
-            _ => Location { start: 0, end: 0 },
-        }
-    }
-}
-
-fn read_node(file: impl AsRef<Path>) -> Result<Node> {
-    let file = file.as_ref();
-    let source = Source::read(file).map_err(SolcError::Resolve)?;
-    let data = parse_data(source.as_ref(), file);
-    Ok(Node { path: file.to_path_buf(), source, data })
-}
-
-/// Extracts the useful data from a solidity source
-///
-/// This will attempt to parse the solidity AST and extract the imports and version pragma. If
-/// parsing fails, we'll fall back to extract that info via regex
-fn parse_data(content: &str, file: &Path) -> SolData {
-    let mut version = None;
-    let mut imports = Vec::<SolDataUnit<PathBuf>>::new();
-    match solang_parser::parse(content, 0) {
-        Ok((units, _)) => {
-            for unit in units.0 {
-                match unit {
-                    SourceUnitPart::PragmaDirective(loc, _, pragma, value) => {
-                        if pragma.name == "solidity" {
-                            // we're only interested in the solidity version pragma
-                            version = Some(SolDataUnit::new(value.string, loc.into()));
-                        }
-                    }
-                    SourceUnitPart::ImportDirective(_, import) => {
-                        let (import, loc) = match import {
-                            Import::Plain(s, l) => (s, l),
-                            Import::GlobalSymbol(s, _, l) => (s, l),
-                            Import::Rename(s, _, l) => (s, l),
-                        };
-                        imports.push(SolDataUnit::new(PathBuf::from(import.string), loc.into()));
-                    }
-                    _ => {}
-                }
-            }
-        }
-        Err(err) => {
-            tracing::trace!(
-                "failed to parse \"{}\" ast: \"{:?}\". Falling back to regex to extract data",
-                file.display(),
-                err
-            );
-            version = capture_outer_and_inner(content, &utils::RE_SOL_PRAGMA_VERSION, &["version"])
-                .first()
-                .map(|(cap, name)| {
-                    SolDataUnit::new(name.as_str().to_owned(), cap.to_owned().into())
-                });
-            imports = capture_imports(content);
-        }
-    };
-    let license = content.lines().next().and_then(|line| {
-        capture_outer_and_inner(line, &utils::RE_SOL_SDPX_LICENSE_IDENTIFIER, &["license"])
-            .first()
-            .map(|(cap, l)| SolDataUnit::new(l.as_str().to_owned(), cap.to_owned().into()))
-    });
-    let version_req = version.as_ref().and_then(|v| Solc::version_req(v.data()).ok());
-    SolData { version_req, version, imports, license }
-}
-
-/// Given the regex and the target string, find all occurrences
-/// of named groups within the string. This method returns
-/// the tuple of matches `(a, b)` where `a` is the match for the
-/// entire regex and `b` is the match for the first named group.
-///
-/// NOTE: This method will return the match for the first named
-/// group, so the order of passed named groups matters.
-fn capture_outer_and_inner<'a>(
-    content: &'a str,
-    regex: &regex::Regex,
-    names: &[&str],
-) -> Vec<(regex::Match<'a>, regex::Match<'a>)> {
-    regex
-        .captures_iter(content)
-        .filter_map(|cap| {
-            let cap_match = names.iter().find_map(|name| cap.name(name));
-            cap_match.and_then(|m| cap.get(0).map(|outer| (outer.to_owned(), m)))
-        })
-        .collect()
-}
-
-fn capture_imports(content: &str) -> Vec<SolDataUnit<PathBuf>> {
-    capture_outer_and_inner(content, &utils::RE_SOL_IMPORT, &["p1", "p2", "p3", "p4"])
-        .iter()
-        .map(|(cap, m)| SolDataUnit::new(PathBuf::from(m.as_str()), cap.to_owned().into()))
-        .collect()
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
 
-    #[test]
-    fn can_capture_curly_imports() {
-        let content = r#"
-import { T } from "../Test.sol";
-import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
-import {DsTest} from "ds-test/test.sol";
-"#;
-
-        let captured_imports =
-            capture_imports(content).into_iter().map(|s| s.data).collect::<Vec<_>>();
-
-        let expected =
-            utils::find_import_paths(content).map(|m| m.as_str().into()).collect::<Vec<PathBuf>>();
-
-        assert_eq!(captured_imports, expected);
-
-        assert_eq!(
-            captured_imports,
-            vec![
-                PathBuf::from("../Test.sol"),
-                "@openzeppelin/contracts/utils/ReentrancyGuard.sol".into(),
-                "ds-test/test.sol".into(),
-            ]
-        );
-    }
-
     #[test]
     fn can_resolve_hardhat_dependency_graph() {
         let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test-data/hardhat-sample");
diff --git a/ethers-solc/src/resolver/parse.rs b/ethers-solc/src/resolver/parse.rs
new file mode 100644
index 00000000..eba8b739
--- /dev/null
+++ b/ethers-solc/src/resolver/parse.rs
@@ -0,0 +1,271 @@
+use crate::{utils, Solc};
+use regex::Match;
+use semver::VersionReq;
+use solang_parser::pt::{
+    ContractPart, ContractTy, FunctionAttribute, FunctionDefinition, Import, Loc, SourceUnitPart,
+    Visibility,
+};
+use std::path::{Path, PathBuf};
+
+/// Represents various information about a solidity file parsed via [solang_parser]
+#[derive(Debug)]
+#[allow(unused)]
+pub struct SolData {
+    pub license: Option<SolDataUnit<String>>,
+    pub version: Option<SolDataUnit<String>>,
+    pub imports: Vec<SolDataUnit<PathBuf>>,
+    pub version_req: Option<VersionReq>,
+    pub libraries: Vec<SolLibrary>,
+    pub contracts: Vec<SolContract>,
+}
+
+impl SolData {
+    #[allow(unused)]
+    pub fn fmt_version<W: std::fmt::Write>(
+        &self,
+        f: &mut W,
+    ) -> std::result::Result<(), std::fmt::Error> {
+        if let Some(ref version) = self.version {
+            write!(f, "({})", version.data)?;
+        }
+        Ok(())
+    }
+
+    /// Extracts the useful data from a solidity source
+    ///
+    /// This will attempt to parse the solidity AST and extract the imports and version pragma. If
+    /// parsing fails, we'll fall back to extract that info via regex
+    pub fn parse(content: &str, file: &Path) -> Self {
+        let mut version = None;
+        let mut imports = Vec::<SolDataUnit<PathBuf>>::new();
+        let mut libraries = Vec::new();
+        let mut contracts = Vec::new();
+
+        match solang_parser::parse(content, 0) {
+            Ok((units, _)) => {
+                for unit in units.0 {
+                    match unit {
+                        SourceUnitPart::PragmaDirective(loc, _, pragma, value) => {
+                            if pragma.name == "solidity" {
+                                // we're only interested in the solidity version pragma
+                                version = Some(SolDataUnit::new(value.string, loc.into()));
+                            }
+                        }
+                        SourceUnitPart::ImportDirective(_, import) => {
+                            let (import, loc) = match import {
+                                Import::Plain(s, l) => (s, l),
+                                Import::GlobalSymbol(s, _, l) => (s, l),
+                                Import::Rename(s, _, l) => (s, l),
+                            };
+                            imports
+                                .push(SolDataUnit::new(PathBuf::from(import.string), loc.into()));
+                        }
+                        SourceUnitPart::ContractDefinition(def) => {
+                            let functions = def
+                                .parts
+                                .into_iter()
+                                .filter_map(|part| match part {
+                                    ContractPart::FunctionDefinition(f) => Some(*f),
+                                    _ => None,
+                                })
+                                .collect();
+                            let name = def.name.name;
+                            match def.ty {
+                                ContractTy::Contract(_) => {
+                                    contracts.push(SolContract { name, functions });
+                                }
+                                ContractTy::Library(_) => {
+                                    libraries.push(SolLibrary { name, functions });
+                                }
+                                _ => {}
+                            }
+                        }
+                        _ => {}
+                    }
+                }
+            }
+            Err(err) => {
+                tracing::trace!(
+                    "failed to parse \"{}\" ast: \"{:?}\". Falling back to regex to extract data",
+                    file.display(),
+                    err
+                );
+                version =
+                    capture_outer_and_inner(content, &utils::RE_SOL_PRAGMA_VERSION, &["version"])
+                        .first()
+                        .map(|(cap, name)| {
+                            SolDataUnit::new(name.as_str().to_owned(), cap.to_owned().into())
+                        });
+                imports = capture_imports(content);
+            }
+        };
+        let license = content.lines().next().and_then(|line| {
+            capture_outer_and_inner(line, &utils::RE_SOL_SDPX_LICENSE_IDENTIFIER, &["license"])
+                .first()
+                .map(|(cap, l)| SolDataUnit::new(l.as_str().to_owned(), cap.to_owned().into()))
+        });
+        let version_req = version.as_ref().and_then(|v| Solc::version_req(v.data()).ok());
+
+        Self { version_req, version, imports, license, libraries, contracts }
+    }
+
+    /// Returns `true` if the solidity file associated with this type contains a solidity library
+    /// that won't be inlined
+    pub fn has_link_references(&self) -> bool {
+        self.libraries.iter().any(|lib| !lib.is_inlined())
+    }
+}
+
+/// Minimal representation of a contract inside a solidity file
+#[derive(Debug)]
+pub struct SolContract {
+    pub name: String,
+    pub functions: Vec<FunctionDefinition>,
+}
+
+/// Minimal representation of a contract inside a solidity file
+#[derive(Debug)]
+pub struct SolLibrary {
+    pub name: String,
+    pub functions: Vec<FunctionDefinition>,
+}
+
+impl SolLibrary {
+    /// Returns `true` if all functions of this library will be inlined.
+    ///
+    /// This checks if all functions are either internal or private, because internal functions can
+    /// only be accessed from within the current contract or contracts deriving from it. They cannot
+    /// be accessed externally. Since they are not exposed to the outside through the contract’s
+    /// ABI, they can take parameters of internal types like mappings or storage references.
+    ///
+    /// See also <https://docs.soliditylang.org/en/latest/contracts.html#libraries>
+    pub fn is_inlined(&self) -> bool {
+        for f in self.functions.iter() {
+            for attr in f.attributes.iter() {
+                if let FunctionAttribute::Visibility(vis) = attr {
+                    match vis {
+                        Visibility::External(_) | Visibility::Public(_) => return false,
+                        _ => {}
+                    }
+                }
+            }
+        }
+        true
+    }
+}
+
+/// Represents an item in a solidity file with its location in the file
+#[derive(Debug, Clone)]
+pub struct SolDataUnit<T> {
+    loc: Location,
+    data: T,
+}
+
+/// Location in a text file buffer
+#[derive(Debug, Clone)]
+pub struct Location {
+    pub start: usize,
+    pub end: usize,
+}
+
+/// Solidity Data Unit decorated with its location within the file
+impl<T> SolDataUnit<T> {
+    pub fn new(data: T, loc: Location) -> Self {
+        Self { data, loc }
+    }
+
+    /// Returns the underlying data for the unit
+    pub fn data(&self) -> &T {
+        &self.data
+    }
+
+    /// Returns the location of the given data unit
+    pub fn loc(&self) -> (usize, usize) {
+        (self.loc.start, self.loc.end)
+    }
+
+    /// Returns the location of the given data unit adjusted by an offset.
+    /// Used to determine new position of the unit within the file after
+    /// content manipulation.
+    pub fn loc_by_offset(&self, offset: isize) -> (usize, usize) {
+        (
+            offset.saturating_add(self.loc.start as isize) as usize,
+            // make the end location exclusive
+            offset.saturating_add(self.loc.end as isize + 1) as usize,
+        )
+    }
+}
+
+impl From<Match<'_>> for Location {
+    fn from(src: Match) -> Self {
+        Location { start: src.start(), end: src.end() }
+    }
+}
+
+impl From<Loc> for Location {
+    fn from(src: Loc) -> Self {
+        match src {
+            Loc::File(_, start, end) => Location { start, end },
+            _ => Location { start: 0, end: 0 },
+        }
+    }
+}
+
+/// Given the regex and the target string, find all occurrences
+/// of named groups within the string. This method returns
+/// the tuple of matches `(a, b)` where `a` is the match for the
+/// entire regex and `b` is the match for the first named group.
+///
+/// NOTE: This method will return the match for the first named
+/// group, so the order of passed named groups matters.
+fn capture_outer_and_inner<'a>(
+    content: &'a str,
+    regex: &regex::Regex,
+    names: &[&str],
+) -> Vec<(regex::Match<'a>, regex::Match<'a>)> {
+    regex
+        .captures_iter(content)
+        .filter_map(|cap| {
+            let cap_match = names.iter().find_map(|name| cap.name(name));
+            cap_match.and_then(|m| cap.get(0).map(|outer| (outer.to_owned(), m)))
+        })
+        .collect()
+}
+
+pub fn capture_imports(content: &str) -> Vec<SolDataUnit<PathBuf>> {
+    capture_outer_and_inner(content, &utils::RE_SOL_IMPORT, &["p1", "p2", "p3", "p4"])
+        .iter()
+        .map(|(cap, m)| SolDataUnit::new(PathBuf::from(m.as_str()), cap.to_owned().into()))
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn can_capture_curly_imports() {
+        let content = r#"
+import { T } from "../Test.sol";
+import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
+import {DsTest} from "ds-test/test.sol";
+"#;
+
+        let captured_imports =
+            capture_imports(content).into_iter().map(|s| s.data).collect::<Vec<_>>();
+
+        let expected =
+            utils::find_import_paths(content).map(|m| m.as_str().into()).collect::<Vec<PathBuf>>();
+
+        assert_eq!(captured_imports, expected);
+
+        assert_eq!(
+            captured_imports,
+            vec![
+                PathBuf::from("../Test.sol"),
+                "@openzeppelin/contracts/utils/ReentrancyGuard.sol".into(),
+                "ds-test/test.sol".into(),
+            ]
+        );
+    }
+}


Test patch:
diff --git a/ethers-solc/tests/project.rs b/ethers-solc/tests/project.rs
index 4188067f..463758c3 100644
--- a/ethers-solc/tests/project.rs
+++ b/ethers-solc/tests/project.rs
@@ -8,11 +8,12 @@ use std::{
 };
 
 use ethers_solc::{
+    artifacts::BytecodeHash,
     cache::{SolFilesCache, SOLIDITY_FILES_CACHE_FILENAME},
     project_util::*,
     remappings::Remapping,
     ConfigurableArtifacts, ExtraOutputValues, Graph, Project, ProjectCompileOutput,
-    ProjectPathsConfig,
+    ProjectPathsConfig, TestFileFilter,
 };
 use pretty_assertions::assert_eq;
 
@@ -737,7 +738,6 @@ fn can_recompile_with_changes() {
 
 #[test]
 fn can_recompile_with_lowercase_names() {
-    init_tracing();
     let tmp = TempProject::dapptools().unwrap();
 
     tmp.add_source(
@@ -843,3 +843,60 @@ fn can_recompile_unchanged_with_empty_files() {
     assert!(compiled.find("A").is_some());
     assert!(compiled.find("C").is_some());
 }
+
+#[test]
+fn can_compile_sparse_with_link_references() {
+    let tmp = TempProject::dapptools().unwrap();
+
+    tmp.add_source(
+        "ATest.t.sol",
+        r#"
+    pragma solidity =0.8.12;
+    import {MyLib} from "./mylib.sol";
+    contract ATest {
+      function test_mylib() public returns (uint256) {
+         return MyLib.doStuff();
+      }
+    }
+   "#,
+    )
+    .unwrap();
+
+    tmp.add_source(
+        "mylib.sol",
+        r#"
+    pragma solidity =0.8.12;
+    library MyLib {
+       function doStuff() external pure returns (uint256) {return 1337;}
+    }
+   "#,
+    )
+    .unwrap();
+
+    let mut compiled = tmp.compile_sparse(TestFileFilter::default()).unwrap();
+    assert!(!compiled.has_compiler_errors());
+
+    assert!(compiled.find("ATest").is_some());
+    assert!(compiled.find("MyLib").is_some());
+    let lib = compiled.remove("MyLib").unwrap();
+    assert!(lib.bytecode.is_some());
+}
+
+#[test]
+fn can_sanitize_bytecode_hash() {
+    let mut tmp = TempProject::dapptools().unwrap();
+    tmp.project_mut().solc_config.settings.metadata = Some(BytecodeHash::Ipfs.into());
+
+    tmp.add_source(
+        "A",
+        r#"
+    pragma solidity =0.5.17;
+    contract A {}
+   "#,
+    )
+    .unwrap();
+
+    let compiled = tmp.compile().unwrap();
+    assert!(!compiled.has_compiler_errors());
+    assert!(compiled.find("A").is_some());
+}