From 247161c01e59455b184a1da6e2195036f39afcf2 Mon Sep 17 00:00:00 2001 From: tash-2s <81064017+tash-2s@users.noreply.github.com> Date: Thu, 28 Dec 2023 14:43:00 -0600 Subject: [PATCH] fix: create verifiable Standard JSON for projects with external files (#36) - Resolves https://github.com/foundry-rs/foundry/issues/5307 Currently, Foundry projects containing Solidity files outside the project root directory face contract verification failures on block explorers. This issue occurs from the Standard JSON including unusable source paths for external files, represented as full absolute paths in their host file systems. This PR addresses the issue by improving the path conversion process. For files not located under the project root directory, relative parent directory paths (`..`) are used, enabling the compiler to find the files within the json. Steps to reproduce the issue are detailed in the linked issue above. Additionally, a test case representing that scenario has been added. With this change, the json created in the reproduction scenario will appear as follows: ```json { "language": "Solidity", "sources": { "src/Counter.sol": { "content": "// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.13;\n\nimport \"@remapped/Parent.sol\";\n\ncontract Counter {\n uint256 public number;\n\n function setNumber(uint256 newNumber) public {\n number = newNumber;\n }\n\n function increment() public {\n number++;\n }\n}\n" }, "../remapped/Parent.sol": { "content": "// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.13;\nimport \"./Child.sol\";\n" }, "../remapped/Child.sol": { "content": "// SPDX-License-Identifier: UNLICENSED\npragma solidity ^0.8.13;\n" } }, "settings": { "remappings": [ "@remapped/=../remapped/", "ds-test/=lib/forge-std/lib/ds-test/src/", "forge-std/=lib/forge-std/src/" ], "optimizer": { "enabled": true, "runs": 200 }, "metadata": { "useLiteralContent": false, "bytecodeHash": "ipfs", "appendCBOR": true }, "outputSelection": { "*": { "": [ "ast" ], "*": [ "abi", "evm.bytecode", "evm.deployedBytecode", "evm.methodIdentifiers", "metadata" ] } }, "evmVersion": "paris", "libraries": {} } } ``` The source path is now aligned with the project root. I have successfully deployed and verified the contract on Etherscan using this change. `forge create --rpc-url "wss://ethereum-holesky.publicnode.com" --verify --verifier-url "https://api-holesky.etherscan.io/api" --etherscan-api-key "..." --private-key "..." src/Counter.sol:Counter` https://holesky.etherscan.io/address/0xe08c332706185521fc8bc2b224f67adf814b1880#code --- src/lib.rs | 106 ++++++++++++++++++++++++++++++++++++++++++----- tests/project.rs | 67 +++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8ae261d0..ac6decb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -490,8 +490,6 @@ impl Project { &self, target: impl AsRef, ) -> Result { - use path_slash::PathExt; - let target = target.as_ref(); tracing::trace!("Building standard-json-input for {:?}", target); let graph = Graph::resolve(&self.paths)?; @@ -514,14 +512,7 @@ impl Project { let root = self.root(); let sources = sources .into_iter() - .map(|(path, source)| { - let path: PathBuf = if let Ok(stripped) = path.strip_prefix(root) { - stripped.to_slash_lossy().into_owned().into() - } else { - path.to_slash_lossy().into_owned().into() - }; - (path, source.clone()) - }) + .map(|(path, source)| (rebase_path(root, path), source.clone())) .collect(); let mut settings = self.solc_config.settings.clone(); @@ -954,6 +945,62 @@ impl ArtifactOutput for Project { } } +// Rebases the given path to the base directory lexically. +// +// For instance, given the base `/home/user/project` and the path `/home/user/project/src/A.sol`, +// this function returns `src/A.sol`. +// +// This function transforms a path into a form that is relative to the base directory. The returned +// path starts either with a normal component (e.g., `src`) or a parent directory component (i.e., +// `..`). It also converts the path into a UTF-8 string and replaces all separators with forward +// slashes (`/`), if they're not. +// +// The rebasing process can be conceptualized as follows: +// +// 1. Remove the leading components from the path that match those in the base. +// 2. Prepend `..` components to the path, matching the number of remaining components in the base. +// +// # Examples +// +// `rebase_path("/home/user/project", "/home/user/project/src/A.sol")` returns `src/A.sol`. The +// common part, `/home/user/project`, is removed from the path. +// +// `rebase_path("/home/user/project", "/home/user/A.sol")` returns `../A.sol`. First, the common +// part, `/home/user`, is removed, leaving `A.sol`. Next, as `project` remains in the base, `..` is +// prepended to the path. +// +// On Windows, paths like `a\b\c` are converted to `a/b/c`. +// +// For more examples, see the test. +fn rebase_path(base: impl AsRef, path: impl AsRef) -> PathBuf { + use path_slash::PathExt; + + let mut base_components = base.as_ref().components(); + let mut path_components = path.as_ref().components(); + + let mut new_path = PathBuf::new(); + + while let Some(path_component) = path_components.next() { + let base_component = base_components.next(); + + if Some(path_component) != base_component { + if base_component.is_some() { + new_path.extend( + std::iter::repeat(std::path::Component::ParentDir) + .take(base_components.count() + 1), + ); + } + + new_path.push(path_component); + new_path.extend(path_components); + + break; + } + } + + new_path.to_slash_lossy().into_owned().into() +} + #[cfg(test)] #[cfg(all(feature = "svm-solc", not(target_arch = "wasm32")))] mod tests { @@ -1014,4 +1061,43 @@ mod tests { let contracts = project.compile().unwrap().succeeded().output().contracts; assert_eq!(contracts.contracts().count(), 2); } + + #[test] + fn can_rebase_path() { + assert_eq!(rebase_path("a/b", "a/b/c"), PathBuf::from("c")); + assert_eq!(rebase_path("a/b", "a/c"), PathBuf::from("../c")); + assert_eq!(rebase_path("a/b", "c"), PathBuf::from("../../c")); + + assert_eq!( + rebase_path("/home/user/project", "/home/user/project/A.sol"), + PathBuf::from("A.sol") + ); + assert_eq!( + rebase_path("/home/user/project", "/home/user/project/src/A.sol"), + PathBuf::from("src/A.sol") + ); + assert_eq!( + rebase_path("/home/user/project", "/home/user/project/lib/forge-std/src/Test.sol"), + PathBuf::from("lib/forge-std/src/Test.sol") + ); + assert_eq!( + rebase_path("/home/user/project", "/home/user/A.sol"), + PathBuf::from("../A.sol") + ); + assert_eq!(rebase_path("/home/user/project", "/home/A.sol"), PathBuf::from("../../A.sol")); + assert_eq!(rebase_path("/home/user/project", "/A.sol"), PathBuf::from("../../../A.sol")); + assert_eq!( + rebase_path("/home/user/project", "/tmp/A.sol"), + PathBuf::from("../../../tmp/A.sol") + ); + + assert_eq!( + rebase_path("/Users/ah/temp/verif", "/Users/ah/temp/remapped/Child.sol"), + PathBuf::from("../remapped/Child.sol") + ); + assert_eq!( + rebase_path("/Users/ah/temp/verif", "/Users/ah/temp/verif/../remapped/Parent.sol"), + PathBuf::from("../remapped/Parent.sol") + ); + } } diff --git a/tests/project.rs b/tests/project.rs index 99dca4ec..5dda8442 100644 --- a/tests/project.rs +++ b/tests/project.rs @@ -12,7 +12,7 @@ use foundry_compilers::{ info::ContractInfo, project_util::*, remappings::Remapping, - Artifact, CompilerInput, ConfigurableArtifacts, ExtraOutputValues, Graph, Project, + utils, Artifact, CompilerInput, ConfigurableArtifacts, ExtraOutputValues, Graph, Project, ProjectCompileOutput, ProjectPathsConfig, Solc, TestFileFilter, }; use pretty_assertions::assert_eq; @@ -1600,6 +1600,71 @@ fn can_sanitize_bytecode_hash() { assert!(compiled.find_first("A").is_some()); } +// https://github.com/foundry-rs/foundry/issues/5307 +#[test] +fn can_create_standard_json_input_with_external_file() { + // File structure: + // . + // ├── verif + // │   └── src + // │   └── Counter.sol + // └── remapped + // ├── Child.sol + // └── Parent.sol + + let dir = tempfile::tempdir().unwrap(); + let verif_dir = utils::canonicalize(dir.path()).unwrap().join("verif"); + let remapped_dir = utils::canonicalize(dir.path()).unwrap().join("remapped"); + fs::create_dir_all(verif_dir.join("src")).unwrap(); + fs::create_dir(&remapped_dir).unwrap(); + + let mut verif_project = Project::builder() + .paths(ProjectPathsConfig::dapptools(&verif_dir).unwrap()) + .build() + .unwrap(); + + verif_project.paths.remappings.push(Remapping { + context: None, + name: "@remapped/".into(), + path: "../remapped/".into(), + }); + verif_project.allowed_paths.insert(remapped_dir.clone()); + + fs::write(remapped_dir.join("Parent.sol"), "pragma solidity >=0.8.0; import './Child.sol';") + .unwrap(); + fs::write(remapped_dir.join("Child.sol"), "pragma solidity >=0.8.0;").unwrap(); + fs::write( + verif_dir.join("src/Counter.sol"), + "pragma solidity >=0.8.0; import '@remapped/Parent.sol'; contract Counter {}", + ) + .unwrap(); + + // solc compiles using the host file system; therefore, this setup is considered valid + let compiled = verif_project.compile().unwrap(); + compiled.assert_success(); + + // can create project root based paths + let std_json = verif_project.standard_json_input(verif_dir.join("src/Counter.sol")).unwrap(); + assert_eq!( + std_json.sources.iter().map(|(path, _)| path.clone()).collect::>(), + vec![ + PathBuf::from("src/Counter.sol"), + PathBuf::from("../remapped/Parent.sol"), + PathBuf::from("../remapped/Child.sol") + ] + ); + + // can compile using the created json + let compiler_errors = Solc::default() + .compile(&std_json) + .unwrap() + .errors + .into_iter() + .filter_map(|e| if e.severity.is_error() { Some(e.message) } else { None }) + .collect::>(); + assert!(compiler_errors.is_empty(), "{:?}", compiler_errors); +} + #[test] fn can_compile_std_json_input() { let tmp = TempProject::dapptools_init().unwrap();