diff --git a/Cargo.lock b/Cargo.lock index 2ee0124255b40..c8e6545369bf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3686,9 +3686,9 @@ dependencies = [ [[package]] name = "foundry-block-explorers" -version = "0.11.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fa56da41a30ea92956a1a19b66ab0e69b31c158fed5c7aabd2d564bfcfe809" +checksum = "f13025e2bf7b3975b34190a7e0fb66b6a5df61e29e54d1da093938073e06ad10" dependencies = [ "alloy-chains", "alloy-json-abi", @@ -3836,10 +3836,13 @@ dependencies = [ "foundry-config", "itertools 0.14.0", "num-format", + "path-slash", "reqwest", "semver 1.0.26", "serde", "serde_json", + "solar-parse", + "solar-sema", "terminal_size", "thiserror 2.0.12", "tokio", @@ -3873,9 +3876,9 @@ dependencies = [ [[package]] name = "foundry-compilers" -version = "0.13.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21089dce1284b88283a6dc1684b22347debb517e86f7e0033e1cf277fda3ea7e" +checksum = "9cc93d4e235ddde616d9fc532bac2cf9840203c3432e24fa48d827a4eb51b35c" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -3898,6 +3901,7 @@ dependencies = [ "serde_json", "sha2", "solar-parse", + "solar-sema", "svm-rs", "svm-rs-builds", "tempfile", @@ -3910,9 +3914,9 @@ dependencies = [ [[package]] name = "foundry-compilers-artifacts" -version = "0.13.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6cc7a5aec89a3e0f271ec522aac586f97d9104c3b31563716f9ca20113e7f5c" +checksum = "59ce306d3199cbea0805df69b62feb4311d5cbcfe0b2d0fe02819a7300e5c42b" dependencies = [ "foundry-compilers-artifacts-solc", "foundry-compilers-artifacts-vyper", @@ -3920,9 +3924,9 @@ dependencies = [ [[package]] name = "foundry-compilers-artifacts-solc" -version = "0.13.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0c1058532f7a062f9bba3cd807f74e034b2f37f8a27d8bcccabdc104102f847" +checksum = "8217869394db052366fc6ed03475929e5c844222885c76d5dfcbb95fecf2640b" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -3944,9 +3948,9 @@ dependencies = [ [[package]] name = "foundry-compilers-artifacts-vyper" -version = "0.13.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f5f4621a0fad72868c5174c01bbfdfb16a6a650edd2f4a41cf5e5dc611a15e" +checksum = "d750e309e1ec20a4fd73617bdbf25529688ff75ae54c79b17b6428e2ce54ca6e" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -3959,9 +3963,9 @@ dependencies = [ [[package]] name = "foundry-compilers-core" -version = "0.13.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3de4acbffff75510c230626f2c3071efa1d6c031afc821a8ed410b67ee6958dd" +checksum = "3a0f2673de9b861d8d7f21a48182f3cb724354e17be084655b5b84d4446f41fc" dependencies = [ "alloy-primitives", "cfg-if", @@ -6362,6 +6366,18 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_map" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd2cae3bec3936bbed1ccc5a3343b3738858182419f9c0522c7260c80c430b0" +dependencies = [ + "ahash", + "hashbrown 0.15.2", + "parking_lot", + "stable_deref_trait", +] + [[package]] name = "op-alloy-consensus" version = "0.11.4" @@ -8428,12 +8444,13 @@ dependencies = [ [[package]] name = "solar-ast" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d3f6c4a476a16dcd36933a70ecdb0a807f8949cc5f3c4c1984e3748666bd714" +checksum = "c0a583a12e73099d1f54bfe7c8a30d7af5ff3591c61ee51cce91045ee5496d86" dependencies = [ "alloy-primitives", "bumpalo", + "derive_more 2.0.1", "either", "num-bigint", "num-rational", @@ -8441,24 +8458,24 @@ dependencies = [ "solar-data-structures", "solar-interface", "solar-macros", - "strum 0.26.3", + "strum 0.27.1", "typed-arena", ] [[package]] name = "solar-config" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d40434a61f2c14a9e3777fbc478167bddee9828532fc26c57e416e9277916b09" +checksum = "12642e7e8490d6855a345b5b9d5e55630bd30f54450a909e28f1385b448baada" dependencies = [ - "strum 0.26.3", + "strum 0.27.1", ] [[package]] name = "solar-data-structures" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d07263243b313296eca18f18eda3a190902dc3284bf67ceff29b8b54dac3e6" +checksum = "dae8902cc28af53e2ba97c450aff7c59d112a433f9ef98fae808e02e25e6dee6" dependencies = [ "bumpalo", "index_vec", @@ -8471,15 +8488,16 @@ dependencies = [ [[package]] name = "solar-interface" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a87009b6989b2cc44d8381e3b86ff3b90280d54a60321919b6416214cd602f3" +checksum = "ded5ec7a5cee351c7a428842d273470180cab259c46f52d502ec3ab5484d0c3a" dependencies = [ "annotate-snippets", "anstream", "anstyle", "const-hex", "derive_builder", + "derive_more 2.0.1", "dunce", "itertools 0.14.0", "itoa", @@ -8499,9 +8517,9 @@ dependencies = [ [[package]] name = "solar-macros" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "970d7c774741f786d62cab78290e47d845b0b9c0c9d094a1642aced1d7946036" +checksum = "ca2c9ff6e00eeeff12eac9d589f1f20413d3b71b9c0c292d1eefbd34787e0836" dependencies = [ "proc-macro2", "quote", @@ -8510,9 +8528,9 @@ dependencies = [ [[package]] name = "solar-parse" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e1e2d07fae218aca1b4cca81216e5c9ad7822516d48a28f11e2eaa8ffa5b249" +checksum = "1e1bc1d0253b0f7f2c7cd25ed7bc5d5e8cac43e717d002398250e0e66e43278b" dependencies = [ "alloy-primitives", "bitflags 2.9.0", @@ -8529,6 +8547,35 @@ dependencies = [ "tracing", ] +[[package]] +name = "solar-sema" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded4b26fb85a0ae2f3277377236af0884c82f38965a2c51046a53016c8b5f332" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "bitflags 2.9.0", + "bumpalo", + "derive_more 2.0.1", + "either", + "once_map", + "paste", + "rayon", + "scc", + "serde", + "serde_json", + "solar-ast", + "solar-data-structures", + "solar-interface", + "solar-macros", + "solar-parse", + "strum 0.27.1", + "thread_local", + "tracing", + "typed-arena", +] + [[package]] name = "soldeer-commands" version = "0.5.3" @@ -10783,4 +10830,4 @@ dependencies = [ "log", "once_cell", "simd-adler32", -] \ No newline at end of file +] diff --git a/Cargo.toml b/Cargo.toml index 1aaa3305ac812..b7d31f092007d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -188,11 +188,12 @@ foundry-wallets = { path = "crates/wallets" } foundry-linking = { path = "crates/linking" } # solc & compilation utilities -foundry-block-explorers = { version = "0.11.0", default-features = false } -foundry-compilers = { version = "0.13.5", default-features = false } +foundry-block-explorers = { version = "0.13.0", default-features = false } +foundry-compilers = { version = "0.14.0", default-features = false } foundry-fork-db = "0.12" solang-parser = "=0.3.3" -solar-parse = { version = "=0.1.1", default-features = false } +solar-parse = { version = "=0.1.2", default-features = false } +solar-sema = { version = "=0.1.2", default-features = false } ## revm revm = { version = "19.4.0", default-features = false } @@ -309,6 +310,7 @@ tracing-subscriber = "0.3" url = "2" vergen = { version = "8", default-features = false } yansi = { version = "1.0", features = ["detect-tty", "detect-env"] } +path-slash = "0.2" [patch.crates-io] ## alloy-core diff --git a/crates/cheatcodes/src/fs.rs b/crates/cheatcodes/src/fs.rs index 554b30e27cc8b..940d5f6d8ef3c 100644 --- a/crates/cheatcodes/src/fs.rs +++ b/crates/cheatcodes/src/fs.rs @@ -371,19 +371,22 @@ fn deploy_code( revm::primitives::CreateScheme::Create }; - let address = executor - .exec_create( - CreateInputs { - caller: ccx.caller, - scheme, - value: value.unwrap_or(U256::ZERO), - init_code: bytecode.into(), - gas_limit: ccx.gas_limit, - }, - ccx, - )? - .address - .ok_or_else(|| fmt_err!("contract creation failed"))?; + let outcome = executor.exec_create( + CreateInputs { + caller: ccx.caller, + scheme, + value: value.unwrap_or(U256::ZERO), + init_code: bytecode.into(), + gas_limit: ccx.gas_limit, + }, + ccx, + )?; + + if !outcome.result.result.is_ok() { + return Err(crate::Error::from(outcome.result.output)) + } + + let address = outcome.address.ok_or_else(|| fmt_err!("contract creation failed"))?; Ok(address.abi_encode()) } diff --git a/crates/cli/src/opts/build/core.rs b/crates/cli/src/opts/build/core.rs index 3b33b58b70aef..652e3973d3276 100644 --- a/crates/cli/src/opts/build/core.rs +++ b/crates/cli/src/opts/build/core.rs @@ -34,6 +34,11 @@ pub struct BuildOpts { #[serde(skip)] pub no_cache: bool, + /// Enable dynamic test linking. + #[arg(long, conflicts_with = "no_cache")] + #[serde(skip)] + pub dynamic_test_linking: bool, + /// Set pre-linked libraries. #[arg(long, help_heading = "Linker options", env = "DAPP_LIBRARIES")] #[serde(skip_serializing_if = "Vec::is_empty")] @@ -245,6 +250,10 @@ impl Provider for BuildOpts { dict.insert("cache".to_string(), false.into()); } + if self.dynamic_test_linking { + dict.insert("dynamic_test_linking".to_string(), true.into()); + } + if self.build_info { dict.insert("build_info".to_string(), self.build_info.into()); } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 314a2c5d0388b..11e67174b65be 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -45,6 +45,9 @@ alloy-transport.workspace = true alloy-consensus = { workspace = true, features = ["k256"] } alloy-network.workspace = true +solar-parse.workspace = true +solar-sema.workspace = true + tower.workspace = true async-trait.workspace = true @@ -64,6 +67,7 @@ tracing.workspace = true url.workspace = true walkdir.workspace = true yansi.workspace = true +path-slash.workspace = true anstream.workspace = true anstyle.workspace = true diff --git a/crates/common/src/compile.rs b/crates/common/src/compile.rs index a9c845c6fee1d..ecae238cabb1d 100644 --- a/crates/common/src/compile.rs +++ b/crates/common/src/compile.rs @@ -1,6 +1,7 @@ //! Support for compiling [foundry_compilers::Project] use crate::{ + preprocessor::TestOptimizerPreprocessor, reports::{report_kind, ReportKind}, shell, term::SpinnerReporter, @@ -16,6 +17,7 @@ use foundry_compilers::{ Compiler, }, info::ContractInfo as CompilerContractInfo, + project::Preprocessor, report::{BasicStdoutReporter, NoReporter, Report}, solc::SolcSettings, Artifact, Project, ProjectBuilder, ProjectCompileOutput, ProjectPathsConfig, SolcConfig, @@ -59,6 +61,9 @@ pub struct ProjectCompiler { /// Extra files to include, that are not necessarily in the project's source dir. files: Vec, + + /// Whether to compile with dynamic linking tests and scripts. + dynamic_test_linking: bool, } impl Default for ProjectCompiler { @@ -81,6 +86,7 @@ impl ProjectCompiler { bail: None, ignore_eip_3860: false, files: Vec::new(), + dynamic_test_linking: false, } } @@ -134,12 +140,22 @@ impl ProjectCompiler { self } + /// Sets if tests should be dynamically linked. + #[inline] + pub fn dynamic_test_linking(mut self, preprocess: bool) -> Self { + self.dynamic_test_linking = preprocess; + self + } + /// Compiles the project. pub fn compile>( mut self, project: &Project, - ) -> Result> { - self.project_root = project.root().clone(); + ) -> Result> + where + TestOptimizerPreprocessor: Preprocessor, + { + self.project_root = project.root().to_path_buf(); // TODO: Avoid process::exit if !project.paths.has_input_files() && self.files.is_empty() { @@ -150,6 +166,7 @@ impl ProjectCompiler { // Taking is fine since we don't need these in `compile_with`. let files = std::mem::take(&mut self.files); + let preprocess = self.dynamic_test_linking; self.compile_with(|| { let sources = if !files.is_empty() { Source::read_all(files)? @@ -157,9 +174,12 @@ impl ProjectCompiler { project.paths.read_input_files()? }; - foundry_compilers::project::ProjectCompiler::with_sources(project, sources)? - .compile() - .map_err(Into::into) + let mut compiler = + foundry_compilers::project::ProjectCompiler::with_sources(project, sources)?; + if preprocess { + compiler = compiler.with_preprocessor(TestOptimizerPreprocessor); + } + compiler.compile().map_err(Into::into) }) } @@ -491,7 +511,10 @@ pub fn compile_target>( target_path: &Path, project: &Project, quiet: bool, -) -> Result> { +) -> Result> +where + TestOptimizerPreprocessor: Preprocessor, +{ ProjectCompiler::new().quiet(quiet).files([target_path.into()]).compile(project) } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 3779492935fcf..5981d317ab1d9 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -25,6 +25,7 @@ pub mod ens; pub mod errors; pub mod evm; pub mod fs; +mod preprocessor; pub mod provider; pub mod reports; pub mod retry; diff --git a/crates/common/src/preprocessor/data.rs b/crates/common/src/preprocessor/data.rs new file mode 100644 index 0000000000000..f7a31686e030a --- /dev/null +++ b/crates/common/src/preprocessor/data.rs @@ -0,0 +1,200 @@ +use super::span_to_range; +use foundry_compilers::artifacts::{Source, Sources}; +use path_slash::PathExt; +use solar_parse::interface::{Session, SourceMap}; +use solar_sema::{ + hir::{Contract, ContractId, Hir}, + interface::source_map::FileName, +}; +use std::{ + collections::{BTreeMap, HashSet}, + path::{Path, PathBuf}, +}; + +/// Keeps data about project contracts definitions referenced from tests and scripts. +/// Contract id -> Contract data definition mapping. +pub type PreprocessorData = BTreeMap; + +/// Collects preprocessor data from referenced contracts. +pub(crate) fn collect_preprocessor_data( + sess: &Session, + hir: &Hir<'_>, + referenced_contracts: &HashSet, +) -> PreprocessorData { + let mut data = PreprocessorData::default(); + for contract_id in referenced_contracts { + let contract = hir.contract(*contract_id); + let source = hir.source(contract.source); + + let FileName::Real(path) = &source.file.name else { + continue; + }; + + let contract_data = + ContractData::new(hir, *contract_id, contract, path, source, sess.source_map()); + data.insert(*contract_id, contract_data); + } + data +} + +/// Creates helper libraries for contracts with a non-empty constructor. +/// +/// See [`ContractData::build_helper`] for more details. +pub(crate) fn create_deploy_helpers(data: &BTreeMap) -> Sources { + let mut deploy_helpers = Sources::new(); + for (contract_id, contract) in data { + if let Some(code) = contract.build_helper() { + let path = format!("foundry-pp/DeployHelper{}.sol", contract_id.get()); + deploy_helpers.insert(path.into(), Source::new(code)); + } + } + deploy_helpers +} + +/// Keeps data about a contract constructor. +#[derive(Debug)] +pub struct ContractConstructorData { + /// ABI encoded args. + pub abi_encode_args: String, + /// Constructor struct fields. + pub struct_fields: String, +} + +/// Keeps data about a single contract definition. +#[derive(Debug)] +pub(crate) struct ContractData { + /// HIR Id of the contract. + contract_id: ContractId, + /// Path of the source file. + path: PathBuf, + /// Name of the contract + name: String, + /// Constructor parameters, if any. + pub constructor_data: Option, + /// Artifact string to pass into cheatcodes. + pub artifact: String, +} + +impl ContractData { + fn new( + hir: &Hir<'_>, + contract_id: ContractId, + contract: &Contract<'_>, + path: &Path, + source: &solar_sema::hir::Source<'_>, + source_map: &SourceMap, + ) -> Self { + let artifact = format!("{}:{}", path.to_slash_lossy(), contract.name); + + // Process data for contracts with constructor and parameters. + let constructor_data = contract + .ctor + .map(|ctor_id| hir.function(ctor_id)) + .filter(|ctor| !ctor.parameters.is_empty()) + .map(|ctor| { + let mut abi_encode_args = vec![]; + let mut struct_fields = vec![]; + let mut arg_index = 0; + for param_id in ctor.parameters { + let src = source.file.src.as_str(); + let loc = span_to_range(source_map, hir.variable(*param_id).span); + let mut new_src = src[loc].replace(" memory ", " ").replace(" calldata ", " "); + if let Some(ident) = hir.variable(*param_id).name { + abi_encode_args.push(format!("args.{}", ident.name)); + } else { + // Generate an unique name if constructor arg doesn't have one. + arg_index += 1; + abi_encode_args.push(format!("args.foundry_pp_ctor_arg{arg_index}")); + new_src.push_str(&format!(" foundry_pp_ctor_arg{arg_index}")); + } + struct_fields.push(new_src); + } + + ContractConstructorData { + abi_encode_args: abi_encode_args.join(", "), + struct_fields: struct_fields.join("; "), + } + }); + + Self { + contract_id, + path: path.to_path_buf(), + name: contract.name.to_string(), + constructor_data, + artifact, + } + } + + /// If contract has a non-empty constructor, generates a helper source file for it containing a + /// helper to encode constructor arguments. + /// + /// This is needed because current preprocessing wraps the arguments, leaving them unchanged. + /// This allows us to handle nested new expressions correctly. However, this requires us to have + /// a way to wrap both named and unnamed arguments. i.e you can't do abi.encode({arg: val}). + /// + /// This function produces a helper struct + a helper function to encode the arguments. The + /// struct is defined in scope of an abstract contract inheriting the contract containing the + /// constructor. This is done as a hack to allow us to inherit the same scope of definitions. + /// + /// The resulted helper looks like this: + /// ```solidity + /// import "lib/openzeppelin-contracts/contracts/token/ERC20.sol"; + /// + /// abstract contract DeployHelper335 is ERC20 { + /// struct ConstructorArgs { + /// string name; + /// string symbol; + /// } + /// } + /// + /// function encodeArgs335(DeployHelper335.ConstructorArgs memory args) pure returns (bytes memory) { + /// return abi.encode(args.name, args.symbol); + /// } + /// ``` + /// + /// Example usage: + /// ```solidity + /// new ERC20(name, symbol) + /// ``` + /// becomes + /// ```solidity + /// vm.deployCode("artifact path", encodeArgs335(DeployHelper335.ConstructorArgs(name, symbol))) + /// ``` + /// With named arguments: + /// ```solidity + /// new ERC20({name: name, symbol: symbol}) + /// ``` + /// becomes + /// ```solidity + /// vm.deployCode("artifact path", encodeArgs335(DeployHelper335.ConstructorArgs({name: name, symbol: symbol}))) + /// ``` + pub fn build_helper(&self) -> Option { + let Self { contract_id, path, name, constructor_data, artifact: _ } = self; + + let Some(constructor_details) = constructor_data else { return None }; + let contract_id = contract_id.get(); + let struct_fields = &constructor_details.struct_fields; + let abi_encode_args = &constructor_details.abi_encode_args; + + let helper = format!( + r#" +pragma solidity >=0.4.0; + +import "{path}"; + +abstract contract DeployHelper{contract_id} is {name} {{ + struct ConstructorArgs {{ + {struct_fields}; + }} +}} + +function encodeArgs{contract_id}(DeployHelper{contract_id}.ConstructorArgs memory args) pure returns (bytes memory) {{ + return abi.encode({abi_encode_args}); +}} + "#, + path = path.to_slash_lossy(), + ); + + Some(helper) + } +} diff --git a/crates/common/src/preprocessor/deps.rs b/crates/common/src/preprocessor/deps.rs new file mode 100644 index 0000000000000..2af9d76ba61af --- /dev/null +++ b/crates/common/src/preprocessor/deps.rs @@ -0,0 +1,370 @@ +use super::{ + data::{ContractData, PreprocessorData}, + span_to_range, +}; +use foundry_compilers::Updates; +use itertools::Itertools; +use solar_parse::interface::Session; +use solar_sema::{ + hir::{ContractId, Expr, ExprKind, Hir, NamedArg, TypeKind, Visit}, + interface::{data_structures::Never, source_map::FileName, SourceMap}, +}; +use std::{ + collections::{BTreeMap, BTreeSet, HashSet}, + ops::{ControlFlow, Range}, + path::{Path, PathBuf}, +}; + +/// Holds data about referenced source contracts and bytecode dependencies. +pub(crate) struct PreprocessorDependencies { + // Mapping contract id to preprocess -> contract bytecode dependencies. + pub preprocessed_contracts: BTreeMap>, + // Referenced contract ids. + pub referenced_contracts: HashSet, +} + +impl PreprocessorDependencies { + pub fn new( + sess: &Session, + hir: &Hir<'_>, + paths: &[PathBuf], + src_dir: &Path, + root_dir: &Path, + mocks: &mut HashSet, + ) -> Self { + let mut preprocessed_contracts = BTreeMap::new(); + let mut referenced_contracts = HashSet::new(); + for contract_id in hir.contract_ids() { + let contract = hir.contract(contract_id); + let source = hir.source(contract.source); + + let FileName::Real(path) = &source.file.name else { + continue; + }; + + // Collect dependencies only for tests and scripts. + if !paths.contains(path) { + let path = path.display(); + trace!("{path} is not test or script"); + continue; + } + + // Do not collect dependencies for mock contracts. Walk through base contracts and + // check if they're from src dir. + if contract.linearized_bases.iter().any(|base_contract_id| { + let base_contract = hir.contract(*base_contract_id); + let FileName::Real(path) = &hir.source(base_contract.source).file.name else { + return false; + }; + path.starts_with(src_dir) + }) { + // Record mock contracts to be evicted from preprocessed cache. + mocks.insert(root_dir.join(path)); + let path = path.display(); + trace!("found mock contract {path}"); + continue; + } else { + // Make sure current contract is not in list of mocks (could happen when a contract + // which used to be a mock is refactored to a non-mock implementation). + mocks.remove(&root_dir.join(path)); + } + + let mut deps_collector = BytecodeDependencyCollector::new( + sess.source_map(), + hir, + source.file.src.as_str(), + src_dir, + ); + // Analyze current contract. + let _ = deps_collector.walk_contract(contract); + // Ignore empty test contracts declared in source files with other contracts. + if !deps_collector.dependencies.is_empty() { + preprocessed_contracts.insert(contract_id, deps_collector.dependencies); + } + // Record collected referenced contract ids. + referenced_contracts.extend(deps_collector.referenced_contracts); + } + Self { preprocessed_contracts, referenced_contracts } + } +} + +/// Represents a bytecode dependency kind. +#[derive(Debug)] +enum BytecodeDependencyKind { + /// `type(Contract).creationCode` + CreationCode, + /// `new Contract`. + New { + /// Contract name. + name: String, + /// Constructor args length. + args_length: usize, + /// Constructor call args offset. + call_args_offset: usize, + /// `msg.value` (if any) used when creating contract. + value: Option, + /// `salt` (if any) used when creating contract. + salt: Option, + }, +} + +/// Represents a single bytecode dependency. +#[derive(Debug)] +pub(crate) struct BytecodeDependency { + /// Dependency kind. + kind: BytecodeDependencyKind, + /// Source map location of this dependency. + loc: Range, + /// HIR id of referenced contract. + referenced_contract: ContractId, +} + +/// Walks over contract HIR and collects [`BytecodeDependency`]s and referenced contracts. +struct BytecodeDependencyCollector<'hir> { + /// Source map, used for determining contract item locations. + source_map: &'hir SourceMap, + /// Parsed HIR. + hir: &'hir Hir<'hir>, + /// Source content of current contract. + src: &'hir str, + /// Project source dir, used to determine if referenced contract is a source contract. + src_dir: &'hir Path, + /// Dependencies collected for current contract. + dependencies: Vec, + /// Unique HIR ids of contracts referenced from current contract. + referenced_contracts: HashSet, +} + +impl<'hir> BytecodeDependencyCollector<'hir> { + fn new( + source_map: &'hir SourceMap, + hir: &'hir Hir<'hir>, + src: &'hir str, + src_dir: &'hir Path, + ) -> Self { + Self { + source_map, + hir, + src, + src_dir, + dependencies: vec![], + referenced_contracts: HashSet::default(), + } + } + + /// Collects reference identified as bytecode dependency of analyzed contract. + /// Discards any reference that is not in project src directory (e.g. external + /// libraries or mock contracts that extend source contracts). + fn collect_dependency(&mut self, dependency: BytecodeDependency) { + let contract = self.hir.contract(dependency.referenced_contract); + let source = self.hir.source(contract.source); + let FileName::Real(path) = &source.file.name else { + return; + }; + + if !path.starts_with(self.src_dir) { + let path = path.display(); + trace!("ignore dependency {path}"); + return; + } + + self.referenced_contracts.insert(dependency.referenced_contract); + self.dependencies.push(dependency); + } +} + +impl<'hir> Visit<'hir> for BytecodeDependencyCollector<'hir> { + type BreakValue = Never; + + fn hir(&self) -> &'hir Hir<'hir> { + self.hir + } + + fn visit_expr(&mut self, expr: &'hir Expr<'hir>) -> ControlFlow { + match &expr.kind { + ExprKind::Call(ty, call_args, named_args) => { + if let ExprKind::New(ty_new) = &ty.kind { + if let TypeKind::Custom(item_id) = ty_new.kind { + if let Some(contract_id) = item_id.as_contract() { + let name_loc = span_to_range(self.source_map, ty_new.span); + let name = &self.src[name_loc]; + + // Calculate offset to remove named args, e.g. for an expression like + // `new Counter {value: 333} ( address(this))` + // the offset will be used to replace `{value: 333} ( ` with `(` + let call_args_offset = if named_args.is_some() && !call_args.is_empty() + { + (call_args.span().lo() - ty_new.span.hi()).to_usize() + } else { + 0 + }; + + let args_len = expr.span.hi() - ty_new.span.hi(); + self.collect_dependency(BytecodeDependency { + kind: BytecodeDependencyKind::New { + name: name.to_string(), + args_length: args_len.to_usize(), + call_args_offset, + value: named_arg( + self.src, + named_args, + "value", + self.source_map, + ), + salt: named_arg(self.src, named_args, "salt", self.source_map), + }, + loc: span_to_range(self.source_map, ty.span), + referenced_contract: contract_id, + }); + } + } + } + } + ExprKind::Member(member_expr, ident) => { + if let ExprKind::TypeCall(ty) = &member_expr.kind { + if let TypeKind::Custom(contract_id) = &ty.kind { + if ident.name.as_str() == "creationCode" { + if let Some(contract_id) = contract_id.as_contract() { + self.collect_dependency(BytecodeDependency { + kind: BytecodeDependencyKind::CreationCode, + loc: span_to_range(self.source_map, expr.span), + referenced_contract: contract_id, + }); + } + } + } + } + } + _ => {} + } + self.walk_expr(expr) + } +} + +/// Helper function to extract value of a given named arg. +fn named_arg( + src: &str, + named_args: &Option<&[NamedArg<'_>]>, + arg: &str, + source_map: &SourceMap, +) -> Option { + named_args.unwrap_or_default().iter().find(|named_arg| named_arg.name.as_str() == arg).map( + |named_arg| { + let named_arg_loc = span_to_range(source_map, named_arg.value.span); + src[named_arg_loc].to_string() + }, + ) +} + +/// Goes over all test/script files and replaces bytecode dependencies with cheatcode +/// invocations. +pub(crate) fn remove_bytecode_dependencies( + hir: &Hir<'_>, + deps: &PreprocessorDependencies, + data: &PreprocessorData, +) -> Updates { + let mut updates = Updates::default(); + for (contract_id, deps) in &deps.preprocessed_contracts { + let contract = hir.contract(*contract_id); + let source = hir.source(contract.source); + let FileName::Real(path) = &source.file.name else { + continue; + }; + + let updates = updates.entry(path.clone()).or_default(); + let mut used_helpers = BTreeSet::new(); + + let vm_interface_name = format!("VmContractHelper{}", contract_id.get()); + // `address(uint160(uint256(keccak256("hevm cheat code"))))` + let vm = format!("{vm_interface_name}(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D)"); + + for dep in deps { + let Some(ContractData { artifact, constructor_data, .. }) = + data.get(&dep.referenced_contract) + else { + continue; + }; + + match &dep.kind { + BytecodeDependencyKind::CreationCode => { + // for creation code we need to just call getCode + updates.insert(( + dep.loc.start, + dep.loc.end, + format!("{vm}.getCode(\"{artifact}\")"), + )); + } + BytecodeDependencyKind::New { + name, + args_length, + call_args_offset, + value, + salt, + } => { + let mut update = format!("{name}(payable({vm}.deployCode({{"); + update.push_str(&format!("_artifact: \"{artifact}\"")); + + if let Some(value) = value { + update.push_str(", "); + update.push_str(&format!("_value: {value}")); + } + + if let Some(salt) = salt { + update.push_str(", "); + update.push_str(&format!("_salt: {salt}")); + } + + if constructor_data.is_some() { + // Insert our helper + used_helpers.insert(dep.referenced_contract); + + update.push_str(", "); + update.push_str(&format!( + "_args: encodeArgs{id}(DeployHelper{id}.ConstructorArgs", + id = dep.referenced_contract.get() + )); + if *call_args_offset > 0 { + update.push('('); + } + updates.insert((dep.loc.start, dep.loc.end + call_args_offset, update)); + updates.insert(( + dep.loc.end + args_length, + dep.loc.end + args_length, + ")})))".to_string(), + )); + } else { + update.push_str("})))"); + updates.insert((dep.loc.start, dep.loc.end + args_length, update)); + } + } + }; + } + let helper_imports = used_helpers.into_iter().map(|id| { + let id = id.get(); + format!( + "import {{DeployHelper{id}, encodeArgs{id}}} from \"foundry-pp/DeployHelper{id}.sol\";", + ) + }).join("\n"); + updates.insert(( + source.file.src.len(), + source.file.src.len(), + format!( + r#" +{helper_imports} + +interface {vm_interface_name} {{ + function deployCode(string memory _artifact) external returns (address); + function deployCode(string memory _artifact, bytes32 _salt) external returns (address); + function deployCode(string memory _artifact, bytes memory _args) external returns (address); + function deployCode(string memory _artifact, bytes memory _args, bytes32 _salt) external returns (address); + function deployCode(string memory _artifact, uint256 _value) external returns (address); + function deployCode(string memory _artifact, uint256 _value, bytes32 _salt) external returns (address); + function deployCode(string memory _artifact, bytes memory _args, uint256 _value) external returns (address); + function deployCode(string memory _artifact, bytes memory _args, uint256 _value, bytes32 _salt) external returns (address); + function getCode(string memory _artifact) external returns (bytes memory); +}}"# + ), + )); + } + updates +} diff --git a/crates/common/src/preprocessor/mod.rs b/crates/common/src/preprocessor/mod.rs new file mode 100644 index 0000000000000..ff8b0e66feae3 --- /dev/null +++ b/crates/common/src/preprocessor/mod.rs @@ -0,0 +1,157 @@ +use foundry_compilers::{ + apply_updates, + artifacts::SolcLanguage, + error::Result, + multi::{MultiCompiler, MultiCompilerInput, MultiCompilerLanguage}, + project::Preprocessor, + solc::{SolcCompiler, SolcVersionedInput}, + Compiler, Language, ProjectPathsConfig, +}; +use solar_parse::{ + ast::Span, + interface::{Session, SourceMap}, +}; +use solar_sema::{thread_local::ThreadLocal, ParsingContext}; +use std::{collections::HashSet, ops::Range, path::PathBuf}; + +mod data; +use data::{collect_preprocessor_data, create_deploy_helpers}; + +mod deps; +use deps::{remove_bytecode_dependencies, PreprocessorDependencies}; + +/// Returns the range of the given span in the source map. +#[track_caller] +fn span_to_range(source_map: &SourceMap, span: Span) -> Range { + source_map.span_to_source(span).unwrap().1 +} + +#[derive(Debug)] +pub struct TestOptimizerPreprocessor; + +impl Preprocessor for TestOptimizerPreprocessor { + fn preprocess( + &self, + _solc: &SolcCompiler, + input: &mut SolcVersionedInput, + paths: &ProjectPathsConfig, + mocks: &mut HashSet, + ) -> Result<()> { + // Skip if we are not preprocessing any tests or scripts. Avoids unnecessary AST parsing. + if !input.input.sources.iter().any(|(path, _)| paths.is_test_or_script(path)) { + trace!("no tests or sources to preprocess"); + return Ok(()); + } + + let sess = solar_session_from_solc(input); + let _ = sess.enter_parallel(|| -> solar_parse::interface::Result { + // Set up the parsing context with the project paths. + let mut parsing_context = solar_pcx_from_solc_no_sources(&sess, input, paths); + + // Add the sources into the context. + // Include all sources in the source map so as to not re-load them from disk, but only + // parse and preprocess tests and scripts. + let mut preprocessed_paths = vec![]; + let sources = &mut input.input.sources; + for (path, source) in sources.iter() { + if let Ok(src_file) = + sess.source_map().new_source_file(path.clone(), source.content.as_str()) + { + if paths.is_test_or_script(path) { + parsing_context.add_file(src_file); + preprocessed_paths.push(path.clone()); + } + } + } + + // Parse and preprocess. + let hir_arena = ThreadLocal::new(); + if let Some(gcx) = parsing_context.parse_and_lower(&hir_arena)? { + let hir = &gcx.get().hir; + // Collect tests and scripts dependencies and identify mock contracts. + let deps = PreprocessorDependencies::new( + &sess, + hir, + &preprocessed_paths, + &paths.paths_relative().sources, + &paths.root, + mocks, + ); + // Collect data of source contracts referenced in tests and scripts. + let data = collect_preprocessor_data(&sess, hir, &deps.referenced_contracts); + + // Extend existing sources with preprocessor deploy helper sources. + sources.extend(create_deploy_helpers(&data)); + + // Generate and apply preprocessor source updates. + apply_updates(sources, remove_bytecode_dependencies(hir, &deps, &data)); + } + + Ok(()) + }); + + // Warn if any diagnostics emitted during content parsing. + if let Err(err) = sess.emitted_errors().unwrap() { + warn!("failed preprocessing {err}"); + } + + Ok(()) + } +} + +impl Preprocessor for TestOptimizerPreprocessor { + fn preprocess( + &self, + compiler: &MultiCompiler, + input: &mut ::Input, + paths: &ProjectPathsConfig, + mocks: &mut HashSet, + ) -> Result<()> { + // Preprocess only Solc compilers. + let MultiCompilerInput::Solc(input) = input else { return Ok(()) }; + + let Some(solc) = &compiler.solc else { return Ok(()) }; + + let paths = paths.clone().with_language::(); + self.preprocess(solc, input, &paths, mocks) + } +} + +fn solar_session_from_solc(solc: &SolcVersionedInput) -> Session { + use solar_parse::interface::config; + + Session::builder() + .with_buffer_emitter(Default::default()) + .opts(config::Opts { + language: match solc.input.language { + SolcLanguage::Solidity => config::Language::Solidity, + SolcLanguage::Yul => config::Language::Yul, + _ => unimplemented!(), + }, + + // TODO: ... + /* + evm_version: solc.input.settings.evm_version, + */ + ..Default::default() + }) + .build() +} + +fn solar_pcx_from_solc_no_sources<'sess>( + sess: &'sess Session, + solc: &SolcVersionedInput, + paths: &ProjectPathsConfig, +) -> ParsingContext<'sess> { + let mut pcx = ParsingContext::new(sess); + pcx.file_resolver.set_current_dir(solc.cli_settings.base_path.as_ref().unwrap_or(&paths.root)); + for remapping in &paths.remappings { + pcx.file_resolver.add_import_remapping(solar_sema::interface::config::ImportRemapping { + context: remapping.context.clone().unwrap_or_default(), + prefix: remapping.name.clone(), + path: remapping.path.clone(), + }); + } + pcx.file_resolver.add_include_paths(solc.cli_settings.include_paths.iter().cloned()); + pcx +} diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 3e7e644b08c21..94c445991b887 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -194,6 +194,8 @@ pub struct Config { pub libraries: Vec, /// whether to enable cache pub cache: bool, + /// whether to dynamically link tests + pub dynamic_test_linking: bool, /// where the cache is stored if enabled pub cache_path: PathBuf, /// where the gas snapshots are stored @@ -1192,7 +1194,7 @@ impl Config { /// Returns configured [Vyper] compiler. pub fn vyper_compiler(&self) -> Result, SolcError> { // Only instantiate Vyper if there are any Vyper files in the project. - if self.project_paths::().input_files_iter().next().is_none() { + if !self.project_paths::().has_input_files() { return Ok(None); } let vyper = if let Some(path) = &self.vyper.path { @@ -1200,7 +1202,6 @@ impl Config { } else { Vyper::new("vyper").ok() }; - Ok(vyper) } @@ -2280,6 +2281,7 @@ impl Default for Config { out: "out".into(), libs: vec!["lib".into()], cache: true, + dynamic_test_linking: false, cache_path: "cache".into(), broadcast: "broadcast".into(), snapshots: "snapshots".into(), diff --git a/crates/forge/src/cmd/build.rs b/crates/forge/src/cmd/build.rs index 3b55aa13fef63..e64569206775b 100644 --- a/crates/forge/src/cmd/build.rs +++ b/crates/forge/src/cmd/build.rs @@ -92,6 +92,7 @@ impl BuildArgs { let format_json = shell::is_json(); let compiler = ProjectCompiler::new() .files(files) + .dynamic_test_linking(config.dynamic_test_linking) .print_names(self.names) .print_sizes(self.sizes) .ignore_eip_3860(self.ignore_eip_3860) diff --git a/crates/forge/src/cmd/test/mod.rs b/crates/forge/src/cmd/test/mod.rs index 6b8d340910809..bf45088dc1000 100644 --- a/crates/forge/src/cmd/test/mod.rs +++ b/crates/forge/src/cmd/test/mod.rs @@ -305,8 +305,10 @@ impl TestArgs { let sources_to_compile = self.get_sources_to_compile(&config, &filter)?; - let compiler = - ProjectCompiler::new().quiet(shell::is_json() || self.junit).files(sources_to_compile); + let compiler = ProjectCompiler::new() + .dynamic_test_linking(config.dynamic_test_linking) + .quiet(shell::is_json() || self.junit) + .files(sources_to_compile); let output = compiler.compile(&project)?; diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index 5dad0d2c73ecb..5308a2918a6aa 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -42,6 +42,7 @@ forgetest!(can_extract_config_values, |prj, cmd| { out: "out-test".into(), libs: vec!["lib-test".into()], cache: true, + dynamic_test_linking: false, cache_path: "test-cache".into(), snapshots: "snapshots".into(), gas_snapshot_check: false, @@ -971,6 +972,7 @@ remappings = ["forge-std/=lib/forge-std/src/"] auto_detect_remappings = true libraries = [] cache = true +dynamic_test_linking = false cache_path = "cache" snapshots = "snapshots" gas_snapshot_check = false @@ -1128,6 +1130,7 @@ exclude = [] "auto_detect_remappings": true, "libraries": [], "cache": true, + "dynamic_test_linking": false, "cache_path": "cache", "snapshots": "snapshots", "gas_snapshot_check": false, diff --git a/crates/forge/tests/cli/main.rs b/crates/forge/tests/cli/main.rs index 87b4729daae9d..d48eae912c050 100644 --- a/crates/forge/tests/cli/main.rs +++ b/crates/forge/tests/cli/main.rs @@ -30,3 +30,4 @@ mod verify_bytecode; mod version; mod ext_integration; +mod test_optimizer; diff --git a/crates/forge/tests/cli/test_optimizer.rs b/crates/forge/tests/cli/test_optimizer.rs new file mode 100644 index 0000000000000..ec95aa12c7cac --- /dev/null +++ b/crates/forge/tests/cli/test_optimizer.rs @@ -0,0 +1,1302 @@ +//! Tests for the `forge test` with preprocessed cache. + +// Test cache is invalidated when `forge build` if optimize test option toggled. +forgetest_init!(toggle_invalidate_cache_on_build, |prj, cmd| { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + // All files are built with optimized tests. + cmd.args(["build"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 22 files with [..] +... + +"#]]); + // No files are rebuilt. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +No files changed, compilation skipped +... + +"#]]); + + // Toggle test optimizer off. + prj.update_config(|config| { + config.dynamic_test_linking = false; + }); + // All files are rebuilt with preprocessed cache false. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 22 files with [..] +... + +"#]]); +}); + +// Test cache is invalidated when `forge test` if optimize test option toggled. +forgetest_init!(toggle_invalidate_cache_on_test, |prj, cmd| { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + // All files are built with optimized tests. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 20 files with [..] +... + +"#]]); + // No files are rebuilt. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +No files changed, compilation skipped +... + +"#]]); + + // Toggle test optimizer off. + prj.update_config(|config| { + config.dynamic_test_linking = false; + }); + // All files are rebuilt with preprocessed cache false. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 20 files with [..] +... + +"#]]); +}); + +// Counter contract without interface instantiated in CounterTest +// +// ├── src +// │ └── Counter.sol +// └── test +// └── Counter.t.sol +forgetest_init!(preprocess_contract_with_no_interface, |prj, cmd| { + prj.wipe_contracts(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function test_SetNumber() public { + counter.setNumber(1); + assertEq(counter.number(), 1); + } +} + "#, + ) + .unwrap(); + // All 20 files are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 20 files with [..] +... + +"#]]); + + // Change Counter implementation to fail both tests. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = 12345; + } + + function increment() public { + number++; + number++; + } +} + "#, + ) + .unwrap(); + // Assert that only 1 file is compiled (Counter source contract) and both tests fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) +[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) +... + +"#]]); + + // Change Counter implementation to fail single test. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = 1; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + // Assert that only 1 file is compiled (Counter source contract) and only one test fails. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: assertion failed: 2 != 1] test_Increment() (gas: [..]) +[PASS] test_SetNumber() (gas: [..]) +... + +"#]]); +}); + +// Counter contract with interface instantiated in CounterTest +// +// ├── src +// │ ├── Counter.sol +// │ └── interface +// │ └── CounterIf.sol +// └── test +// └── Counter.t.sol +forgetest_init!(preprocess_contract_with_interface, |prj, cmd| { + prj.wipe_contracts(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "interface/CounterIf.sol", + r#" +interface CounterIf { + function number() external returns (uint256); + + function setNumber(uint256 newNumber) external; + + function increment() external; +} + "#, + ) + .unwrap(); + prj.add_source( + "Counter.sol", + r#" +import {CounterIf} from "./interface/CounterIf.sol"; +contract Counter is CounterIf { + uint256 public number; + uint256 public anotherNumber; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = Counter(address(new Counter())); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function test_SetNumber() public { + counter.setNumber(1); + assertEq(counter.number(), 1); + } +} + "#, + ) + .unwrap(); + // All 21 files are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... + +"#]]); + + // Change only CounterIf interface. + prj.add_source( + "interface/CounterIf.sol", + r#" +interface CounterIf { + function anotherNumber() external returns (uint256); + + function number() external returns (uint256); + + function setNumber(uint256 newNumber) external; + + function increment() external; +} + "#, + ) + .unwrap(); + // All 3 files (interface, implementation and test) are compiled. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 3 files with [..] +... + +"#]]); + + // Change Counter implementation to fail both tests. + prj.add_source( + "Counter.sol", + r#" +import {CounterIf} from "./interface/CounterIf.sol"; +contract Counter is CounterIf { + uint256 public number; + uint256 public anotherNumber; + + function setNumber(uint256 newNumber) public { + number = 12345; + } + + function increment() public { + number++; + number++; + } +} + "#, + ) + .unwrap(); + // Assert that only 1 file is compiled (Counter source contract) and both tests fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) +[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) +... + +"#]]); +}); + +// - Counter contract instantiated in CounterMock +// - CounterMock instantiated in CounterTest +// +// ├── src +// │ └── Counter.sol +// └── test +// ├── Counter.t.sol +// └── mock +// └── CounterMock.sol +forgetest_init!(preprocess_mock_without_inheritance, |prj, cmd| { + prj.wipe_contracts(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + + prj.add_test( + "mock/CounterMock.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "src/Counter.sol"; + +contract CounterMock { + Counter counter = new Counter(); + + function setNumber(uint256 newNumber) public { + counter.setNumber(newNumber); + } + + function increment() public { + counter.increment(); + } + + function number() public returns (uint256) { + return counter.number(); + } +} + "#, + ) + .unwrap(); + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {CounterMock} from "./mock/CounterMock.sol"; + +contract CounterTest is Test { + CounterMock public counter; + + function setUp() public { + counter = new CounterMock(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function test_SetNumber() public { + counter.setNumber(1); + assertEq(counter.number(), 1); + } +} + "#, + ) + .unwrap(); + // 20 files plus one mock file are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... + +"#]]); + + // Change Counter contract implementation to fail both tests. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = 12345; + } + + function increment() public { + number++; + number++; + } +} + "#, + ) + .unwrap(); + // Assert that only 1 file is compiled (Counter source contract) and both tests fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) +[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) +... + +"#]]); + + // Change CounterMock contract implementation to pass both tests. + prj.add_test( + "mock/CounterMock.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "src/Counter.sol"; + +contract CounterMock { + Counter counter = new Counter(); + + function setNumber(uint256 newNumber) public { + } + + function increment() public { + } + + function number() public returns (uint256) { + return 1; + } +} + "#, + ) + .unwrap(); + // Assert that mock and test files are compiled and no test fails. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 2 files with [..] +... +[PASS] test_Increment() (gas: [..]) +[PASS] test_SetNumber() (gas: [..]) +... + +"#]]); +}); + +// - CounterMock contract is Counter contract +// - CounterMock instantiated in CounterTest +// +// ├── src +// │ └── Counter.sol +// └── test +// ├── Counter.t.sol +// └── mock +// └── CounterMock.sol +forgetest_init!(preprocess_mock_with_inheritance, |prj, cmd| { + prj.wipe_contracts(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + + prj.add_test( + "mock/CounterMock.sol", + r#" +import {Counter} from "src/Counter.sol"; + +contract CounterMock is Counter { +} + "#, + ) + .unwrap(); + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {CounterMock} from "./mock/CounterMock.sol"; + +contract CounterTest is Test { + CounterMock public counter; + + function setUp() public { + counter = new CounterMock(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function test_SetNumber() public { + counter.setNumber(1); + assertEq(counter.number(), 1); + } +} + "#, + ) + .unwrap(); + // 20 files plus one mock file are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... + +"#]]); + + // Change Counter contract implementation to fail both tests. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public virtual { + number = 12345; + } + + function increment() public virtual { + number++; + number++; + } +} + "#, + ) + .unwrap(); + // Assert Counter source contract and CounterTest test contract (as it imports mock) are + // compiled and both tests fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 2 files with [..] +... +[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) +[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) +... + +"#]]); + + // Change mock implementation to pass both tests. + prj.add_test( + "mock/CounterMock.sol", + r#" +import {Counter} from "src/Counter.sol"; + +contract CounterMock is Counter { + function setNumber(uint256 newNumber) public override { + number = newNumber; + } + + function increment() public override { + number++; + } +} + "#, + ) + .unwrap(); + // Assert that CounterMock and CounterTest files are compiled and no test fails. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 2 files with [..] +... +[PASS] test_Increment() (gas: [..]) +[PASS] test_SetNumber() (gas: [..]) +... + +"#]]); +}); + +// - CounterMock contract is Counter contract +// - CounterMock instantiated in CounterTest +// +// ├── src +// │ └── Counter.sol +// └── test +// ├── Counter.t.sol +// └── mock +// └── CounterMock.sol +forgetest_init!(preprocess_mock_to_non_mock, |prj, cmd| { + prj.wipe_contracts(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + + prj.add_test( + "mock/CounterMock.sol", + r#" +import {Counter} from "src/Counter.sol"; + +contract CounterMock is Counter { +} + "#, + ) + .unwrap(); + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {CounterMock} from "./mock/CounterMock.sol"; + +contract CounterTest is Test { + CounterMock public counter; + + function setUp() public { + counter = new CounterMock(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function test_SetNumber() public { + counter.setNumber(1); + assertEq(counter.number(), 1); + } +} + "#, + ) + .unwrap(); + // 20 files plus one mock file are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... + +"#]]); + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +No files changed, compilation skipped +... + +"#]]); + + // Change mock implementation to fail tests, no inherit from Counter. + prj.add_test( + "mock/CounterMock.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "src/Counter.sol"; + +contract CounterMock { + uint256 public number; + function setNumber(uint256 newNumber) public { + number = 1234; + } + + function increment() public { + number = 5678; + } +} + "#, + ) + .unwrap(); + // Assert that CounterMock and CounterTest files are compiled and tests fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 2 files with [..] +... +[FAIL: assertion failed: 5678 != 1] test_Increment() (gas: [..]) +[FAIL: assertion failed: 1234 != 1] test_SetNumber() (gas: [..]) +... + +"#]]); +}); + +// ├── src +// │ ├── CounterA.sol +// │ ├── CounterB.sol +// │ ├── Counter.sol +// │ └── v1 +// │ └── Counter.sol +// └── test +// └── Counter.t.sol +forgetest_init!(preprocess_multiple_contracts_with_constructors, |prj, cmd| { + prj.wipe_contracts(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + prj.add_source( + "CounterA.sol", + r#" +contract CounterA { + uint256 public number; + address public owner; + + constructor(uint256 _newNumber, address _owner) { + number = _newNumber; + owner = _owner; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + // Contract with constructor args without name. + prj.add_source( + "CounterB.sol", + r#" +contract CounterB { + uint256 public number; + + constructor(uint256) { + number = 1; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + prj.add_source( + "v1/Counter.sol", + r#" +contract Counter { + uint256 public number; + + constructor(uint256 _number) { + number = _number; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "src/Counter.sol"; +import "src/CounterA.sol"; +import "src/CounterB.sol"; +import {Counter as CounterV1} from "src/v1/Counter.sol"; + +contract CounterTest is Test { + function test_Increment_In_Counter() public { + Counter counter = new Counter(); + counter.increment(); + assertEq(counter.number(), 1); + } + + function test_Increment_In_Counter_V1() public { + CounterV1 counter = new CounterV1(1234); + counter.increment(); + assertEq(counter.number(), 1235); + } + + function test_Increment_In_Counter_A() public { + CounterA counter = new CounterA(1234, address(this)); + counter.increment(); + assertEq(counter.number(), 1235); + } + + function test_Increment_In_Counter_A_with_named_args() public { + CounterA counter = new CounterA({_newNumber: 1234, _owner: address(this)}); + counter.increment(); + assertEq(counter.number(), 1235); + } + + function test_Increment_In_Counter_B() public { + CounterB counter = new CounterB(1234); + counter.increment(); + assertEq(counter.number(), 2); + } +} + "#, + ) + .unwrap(); + // 22 files plus one mock file are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 23 files with [..] +... +[PASS] test_Increment_In_Counter() (gas: [..]) +[PASS] test_Increment_In_Counter_A() (gas: [..]) +[PASS] test_Increment_In_Counter_A_with_named_args() (gas: [..]) +[PASS] test_Increment_In_Counter_B() (gas: [..]) +[PASS] test_Increment_In_Counter_V1() (gas: [..]) +... + +"#]]); + + // Change v1/Counter to fail test. + prj.add_source( + "v1/Counter.sol", + r#" +contract Counter { + uint256 public number; + + constructor(uint256 _number) { + number = _number; + } + + function increment() public { + number = 12345; + } +} + "#, + ) + .unwrap(); + // Only v1/Counter should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[PASS] test_Increment_In_Counter() (gas: [..]) +[PASS] test_Increment_In_Counter_A() (gas: [..]) +[PASS] test_Increment_In_Counter_A_with_named_args() (gas: [..]) +[PASS] test_Increment_In_Counter_B() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) +... + +"#]]); + + // Change CounterA to fail test. + prj.add_source( + "CounterA.sol", + r#" +contract CounterA { + uint256 public number; + address public owner; + + constructor(uint256 _newNumber, address _owner) { + number = _newNumber; + owner = _owner; + } + + function increment() public { + number = 12345; + } +} + "#, + ) + .unwrap(); + // Only CounterA should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[PASS] test_Increment_In_Counter() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A_with_named_args() (gas: [..]) +[PASS] test_Increment_In_Counter_B() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) +... + +"#]]); + + // Change CounterB to fail test. + prj.add_source( + "CounterB.sol", + r#" +contract CounterB { + uint256 public number; + + constructor(uint256) { + number = 100; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + // Only CounterB should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[PASS] test_Increment_In_Counter() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A_with_named_args() (gas: [..]) +[FAIL: assertion failed: 101 != 2] test_Increment_In_Counter_B() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) +... + +"#]]); + + // Change Counter to fail test. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number = 12345; + } +} + "#, + ) + .unwrap(); + // Only Counter should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: assertion failed: 12345 != 1] test_Increment_In_Counter() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A_with_named_args() (gas: [..]) +[FAIL: assertion failed: 101 != 2] test_Increment_In_Counter_B() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) +... + +"#]]); +}); + +// Test preprocessing contracts with payable constructor, value and salt named args. +forgetest_init!(preprocess_contracts_with_payable_constructor_and_salt, |prj, cmd| { + prj.wipe_contracts(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + constructor(uint256 _number) payable { + number = msg.value; + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + prj.add_source( + "CounterWithSalt.sol", + r#" +contract CounterWithSalt { + uint256 public number; + + constructor(uint256 _number) payable { + number = msg.value; + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "src/Counter.sol"; +import {CounterWithSalt} from "src/CounterWithSalt.sol"; + +contract CounterTest is Test { + function test_Increment_In_Counter() public { + Counter counter = Counter(address(new Counter{value: 111}(1))); + counter.increment(); + assertEq(counter.number(), 112); + } + + function test_Increment_In_Counter_With_Salt() public { + CounterWithSalt counter = new CounterWithSalt{value: 111, salt: bytes32("preprocess_counter_with_salt")}(1); + assertEq(address(counter), 0x3Efe9ecFc73fB3baB7ECafBB40D3e134260Be6AB); + } +} + "#, + ) + .unwrap(); + + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... +[PASS] test_Increment_In_Counter() (gas: [..]) +[PASS] test_Increment_In_Counter_With_Salt() (gas: [..]) +... + +"#]]); + + // Change contract to fail test. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + constructor(uint256 _number) payable { + number = msg.value + _number; + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + // Only Counter should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: assertion failed: 113 != 112] test_Increment_In_Counter() (gas: [..]) +[PASS] test_Increment_In_Counter_With_Salt() (gas: [..]) +... + +"#]]); + + // Change contract with salt to fail test too. + prj.add_source( + "CounterWithSalt.sol", + r#" +contract CounterWithSalt { + uint256 public number; + + constructor(uint256 _number) payable { + number = msg.value + _number; + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ) + .unwrap(); + // Only Counter should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: assertion failed: 113 != 112] test_Increment_In_Counter() (gas: [..]) +[FAIL: assertion failed: 0x6cDcb015cFcAd0C23560322EdEE8f324520E4b93 != 0x3Efe9ecFc73fB3baB7ECafBB40D3e134260Be6AB] test_Increment_In_Counter_With_Salt() (gas: [..]) +... + +"#]]); +}); + +// Counter contract with constructor reverts and emitted events. +forgetest_init!(preprocess_contract_with_require_and_emit, |prj, cmd| { + prj.wipe_contracts(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + event CounterCreated(uint256 number); + uint256 public number; + + constructor(uint256 no) { + require(no != 1, "ctor revert"); + emit CounterCreated(10); + } +} + "#, + ) + .unwrap(); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + function test_assert_constructor_revert() public { + vm.expectRevert("ctor revert"); + new Counter(1); + } + + function test_assert_constructor_emit() public { + vm.expectEmit(true, true, true, true); + emit Counter.CounterCreated(10); + + new Counter(11); + } +} + "#, + ) + .unwrap(); + // All 20 files are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 20 files with [..] +... + +"#]]); + + // Change Counter implementation to revert with different message. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + event CounterCreated(uint256 number); + uint256 public number; + + constructor(uint256 no) { + require(no != 1, "ctor revert update"); + emit CounterCreated(10); + } +} + "#, + ) + .unwrap(); + // Assert that only 1 file is compiled (Counter source contract) and revert test fails. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[PASS] test_assert_constructor_emit() (gas: [..]) +[FAIL: Error != expected error: ctor revert update != ctor revert] test_assert_constructor_revert() (gas: [..]) +... + +"#]]); + + // Change Counter implementation and don't revert. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + event CounterCreated(uint256 number); + uint256 public number; + + constructor(uint256 no) { + require(no != 0, "ctor revert"); + emit CounterCreated(10); + } +} + "#, + ) + .unwrap(); + // Assert that only 1 file is compiled (Counter source contract) and revert test fails. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[PASS] test_assert_constructor_emit() (gas: [..]) +[FAIL: next call did not revert as expected] test_assert_constructor_revert() (gas: [..]) +... + +"#]]); + + // Change Counter implementation to emit different event. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + event CounterCreated(uint256 number); + uint256 public number; + + constructor(uint256 no) { + require(no != 0, "ctor revert"); + emit CounterCreated(100); + } +} + "#, + ) + .unwrap(); + // Assert that only 1 file is compiled (Counter source contract) and emit test fails. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: expected an emit, but no logs were emitted afterwards. you might have mismatched events or not enough events were emitted] test_assert_constructor_emit() (gas: [..]) +[FAIL: next call did not revert as expected] test_assert_constructor_revert() (gas: [..]) +... + +"#]]); +}); diff --git a/crates/script/src/build.rs b/crates/script/src/build.rs index d3802d60c38bc..885e7ca5879be 100644 --- a/crates/script/src/build.rs +++ b/crates/script/src/build.rs @@ -229,7 +229,7 @@ impl PreprocessedState { args, script_config, script_wallets, - build_data: BuildData { output, target, project_root: project.root().clone() }, + build_data: BuildData { output, target, project_root: project.root().to_path_buf() }, }) } } diff --git a/crates/verify/src/provider.rs b/crates/verify/src/provider.rs index 01a06332a36ce..74d2094c41b83 100644 --- a/crates/verify/src/provider.rs +++ b/crates/verify/src/provider.rs @@ -91,7 +91,7 @@ impl VerificationContext { let graph = Graph::::resolve_sources(&self.project.paths, sources)?; - Ok(graph.imports(&self.target_path).into_iter().cloned().collect()) + Ok(graph.imports(&self.target_path).into_iter().map(Into::into).collect()) } }