From c7cf5bedbdb461fbd6605684ff398b98329920ae Mon Sep 17 00:00:00 2001 From: Matthias Seitz Date: Thu, 23 Dec 2021 15:38:07 +0100 Subject: [PATCH] feat(abigen): add MultiAbigen to generate multiple contract bindings (#724) * feat(abigen): add MultiAbigen to generate multiple contract bindings * docs: more docs * chore: update changelog * rustmft * chore: add json extension check --- CHANGELOG.md | 2 + Cargo.lock | 1 + .../ethers-contract-abigen/Cargo.toml | 3 + .../ethers-contract-abigen/src/contract.rs | 43 +-- .../ethers-contract-abigen/src/lib.rs | 292 +++++++++++++++++- 5 files changed, 320 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 513acd34..580f6330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ ## ethers-contract-abigen +- Add `MultiAbigen` to generate a series of contract bindings that can be kept in the repo + [#724](https://github.com/gakonst/ethers-rs/pull/724). - Add provided `event_derives` to call and event enums as well [#721](https://github.com/gakonst/ethers-rs/pull/721). - Implement snowtrace and polygonscan on par with the etherscan integration diff --git a/Cargo.lock b/Cargo.lock index d0991620..814545a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1109,6 +1109,7 @@ dependencies = [ "serde", "serde_json", "syn", + "tempfile", "url", ] diff --git a/ethers-contract/ethers-contract-abigen/Cargo.toml b/ethers-contract/ethers-contract-abigen/Cargo.toml index ea76e64d..3bcb70a9 100644 --- a/ethers-contract/ethers-contract-abigen/Cargo.toml +++ b/ethers-contract/ethers-contract-abigen/Cargo.toml @@ -38,3 +38,6 @@ rustdoc-args = ["--cfg", "docsrs"] default = ["reqwest", "rustls"] openssl = ["reqwest/native-tls"] rustls = ["reqwest/rustls-tls"] + +[dev-dependencies] +tempfile = "3.2.0" diff --git a/ethers-contract/ethers-contract-abigen/src/contract.rs b/ethers-contract/ethers-contract-abigen/src/contract.rs index b7af7dbb..b47c8d50 100644 --- a/ethers-contract/ethers-contract-abigen/src/contract.rs +++ b/ethers-contract/ethers-contract-abigen/src/contract.rs @@ -154,27 +154,8 @@ impl Context { // get the actual ABI string let abi_str = args.abi_source.get().map_err(|e| anyhow!("failed to get ABI JSON: {}", e))?; - let mut abi_parser = AbiParser::default(); - let (abi, human_readable): (Abi, _) = if let Ok(abi) = abi_parser.parse_str(&abi_str) { - (abi, true) - } else { - // a best-effort coercion of an ABI or an artifact JSON into an artifact JSON. - let json_abi_str = if abi_str.trim().starts_with('[') { - format!(r#"{{"abi":{}}}"#, abi_str.trim()) - } else { - abi_str.clone() - }; - - #[derive(Deserialize)] - struct Contract { - abi: Abi, - } - - let contract = serde_json::from_str::(&json_abi_str)?; - - (contract.abi, false) - }; + let (abi, human_readable, abi_parser) = parse_abi(&abi_str)?; // try to extract all the solidity structs from the normal JSON ABI // we need to parse the json abi again because we need the internalType fields which are @@ -251,3 +232,25 @@ impl Context { &mut self.internal_structs } } + +/// Parse the abi via `Source::parse` and return if the abi defined as human readable +fn parse_abi(abi_str: &str) -> Result<(Abi, bool, AbiParser)> { + let mut abi_parser = AbiParser::default(); + let res = if let Ok(abi) = abi_parser.parse_str(abi_str) { + (abi, true, abi_parser) + } else { + #[derive(Deserialize)] + struct Contract { + abi: Abi, + } + // a best-effort coercion of an ABI or an artifact JSON into an artifact JSON. + let contract: Contract = if abi_str.trim_start().starts_with('[') { + serde_json::from_str(&format!(r#"{{"abi":{}}}"#, abi_str.trim()))? + } else { + serde_json::from_str::(abi_str)? + }; + + (contract.abi, false, abi_parser) + }; + Ok(res) +} diff --git a/ethers-contract/ethers-contract-abigen/src/lib.rs b/ethers-contract/ethers-contract-abigen/src/lib.rs index ef243408..ea4bfd1d 100644 --- a/ethers-contract/ethers-contract-abigen/src/lib.rs +++ b/ethers-contract/ethers-contract-abigen/src/lib.rs @@ -24,8 +24,14 @@ pub use source::Source; pub use util::parse_address; use anyhow::Result; +use inflector::Inflector; use proc_macro2::TokenStream; -use std::{collections::HashMap, fs::File, io::Write, path::Path}; +use std::{ + collections::HashMap, + fs::{self, File}, + io::Write, + path::Path, +}; /// Builder struct for generating type-safe bindings from a contract's ABI /// @@ -44,6 +50,7 @@ use std::{collections::HashMap, fs::File, io::Write, path::Path}; /// Abigen::new("ERC20Token", "./abi.json")?.generate()?.write_to_file("token.rs")?; /// # Ok(()) /// # } +#[derive(Debug, Clone)] pub struct Abigen { /// The source of the ABI JSON for the contract whose bindings /// are being generated. @@ -180,3 +187,286 @@ impl ContractBindings { self.tokens } } + +/// Generates bindings for a series of contracts +/// +/// This type can be used to generate multiple `ContractBindings` and put them all in a single rust +/// module, (eg. a `contracts` directory). +/// +/// This can be used to +/// 1) write all bindings directly into a new directory in the project's source directory, so that +/// it is included in the repository. 2) write all bindings to the value of cargo's `OUT_DIR` in a +/// build script and import the bindings as `include!(concat!(env!("OUT_DIR"), "/mod.rs"));`. +/// +/// However, the main purpose of this generator is to create bindings for option `1)` and write all +/// contracts to some `contracts` module in `src`, like `src/contracts/mod.rs` __once__ via a build +/// script or a test. After that it's recommend to remove the build script and replace it with an +/// integration test (See `MultiAbigen::ensure_consistent_bindings`) that fails if the generated +/// code is out of date. This has several advantages: +/// +/// * No need for downstream users to compile the build script +/// * No need for downstream users to run the whole `abigen!` generation steps +/// * The generated code is more usable in an IDE +/// * CI will fail if the generated code is out of date (if `abigen!` or the contract's ABI itself +/// changed) +/// +/// See `MultiAbigen::ensure_consistent_bindings` for the recommended way to set this up to generate +/// the bindings once via a test and then use the test to ensure consistency. +#[derive(Debug, Clone)] +pub struct MultiAbigen { + /// whether to write all contracts in a single file instead of separated modules + single_file: bool, + + abigens: Vec, +} + +impl MultiAbigen { + /// Create a new instance from a series of already resolved `Abigen` + pub fn from_abigen(abis: impl IntoIterator) -> Self { + Self { + single_file: false, + abigens: abis.into_iter().map(|abi| abi.rustfmt(true)).collect(), + } + } + + /// Create a new instance from a series (`contract name`, `abi_source`) + /// + /// See `Abigen::new` + pub fn new(abis: I) -> Result + where + I: IntoIterator, + Name: AsRef, + Source: AsRef, + { + let abis = abis + .into_iter() + .map(|(contract_name, abi_source)| Abigen::new(contract_name.as_ref(), abi_source)) + .collect::>>()?; + + Ok(Self::from_abigen(abis)) + } + + /// Reads all json files contained in the given `dir` and use the file name for the name of the + /// `ContractBindings`. + /// This is equivalent to calling `MultiAbigen::new` with all the json files and their filename. + /// + /// # Example + /// + /// ```text + /// abi + /// ├── ERC20.json + /// ├── Contract1.json + /// ├── Contract2.json + /// ... + /// ``` + /// + /// ```no_run + /// # use ethers_contract_abigen::MultiAbigen; + /// let gen = MultiAbigen::from_json_files("./abi").unwrap(); + /// ``` + pub fn from_json_files(dir: impl AsRef) -> Result { + let mut abis = Vec::new(); + for file in fs::read_dir(dir)?.into_iter().filter_map(std::io::Result::ok).filter(|p| { + p.path().is_file() && p.path().extension().and_then(|ext| ext.to_str()) == Some("json") + }) { + let file: fs::DirEntry = file; + if let Some(file_name) = file.path().file_stem().and_then(|s| s.to_str()) { + let content = fs::read_to_string(file.path())?; + abis.push((file_name.to_string(), content)); + } + } + Self::new(abis) + } + + /// Write all bindings into a single rust file instead of separate modules + #[must_use] + pub fn single_file(mut self) -> Self { + self.single_file = true; + self + } + + /// Generates all the bindings and writes them to the given module + /// + /// # Example + /// + /// Read all json abi files from the `./abi` directory + /// ```text + /// abi + /// ├── ERC20.json + /// ├── Contract1.json + /// ├── Contract2.json + /// ... + /// ``` + /// + /// and write them to the `./src/contracts` location as + /// + /// ```text + /// src/contracts + /// ├── mod.rs + /// ├── er20.rs + /// ├── contract1.rs + /// ├── contract2.rs + /// ... + /// ``` + /// + /// ```no_run + /// # use ethers_contract_abigen::MultiAbigen; + /// let gen = MultiAbigen::from_json_files("./abi").unwrap(); + /// gen.write_to_module("./src/contracts").unwrap(); + /// ``` + pub fn write_to_module(self, module: impl AsRef) -> Result<()> { + let module = module.as_ref(); + fs::create_dir_all(module)?; + + let mut contracts_mod = + b"/// This module contains all the autogenerated abigen! contract bindings\n".to_vec(); + + let mut modules = Vec::new(); + for abi in self.abigens { + let name = abi.contract_name.to_snake_case(); + let bindings = abi.generate()?; + if self.single_file { + // append to the mod file + bindings.write(&mut contracts_mod)?; + } else { + // create a contract rust file + let output = module.join(format!("{}.rs", name)); + bindings.write_to_file(output)?; + modules.push(format!("pub mod {};", name)); + } + } + + if !modules.is_empty() { + modules.sort(); + write!(contracts_mod, "{}", modules.join("\n"))?; + } + + // write the mod file + fs::write(module.join("mod.rs"), contracts_mod)?; + + Ok(()) + } + + /// This ensures that the already generated contract bindings match the output of a fresh new + /// run. Run this in a rust test, to get notified in CI if the newly generated bindings + /// deviate from the already generated ones, and it's time to generate them again. This could + /// happen if the ABI of a contract or the output that `ethers` generates changed. + /// + /// So if this functions is run within a test during CI and fails, then it's time to update all + /// bindings. + /// + /// Returns `true` if the freshly generated bindings match with the existing bindings, `false` + /// otherwise + /// + /// # Example + /// + /// Check that the generated files are up to date + /// + /// ```no_run + /// # use ethers_contract_abigen::MultiAbigen; + /// #[test] + /// fn generated_bindings_are_fresh() { + /// let project_root = std::path::Path::new(&env!("CARGO_MANIFEST_DIR")); + /// let abi_dir = project_root.join("abi"); + /// let gen = MultiAbigen::from_json_files(&abi_dir).unwrap(); + /// assert!(gen.ensure_consistent_bindings(project_root.join("src/contracts"))); + /// } + /// + /// gen.write_to_module("./src/contracts").unwrap(); + /// ``` + #[cfg(test)] + pub fn ensure_consistent_bindings(self, module: impl AsRef) -> bool { + let module = module.as_ref(); + let dir = tempfile::tempdir().expect("Failed to create temp dir"); + let temp_module = dir.path().join("contracts"); + self.write_to_module(&temp_module).expect("Failed to generate bindings"); + + for file in fs::read_dir(&temp_module).unwrap() { + let fresh_file = file.unwrap(); + let fresh_file_path = fresh_file.path(); + let file_name = fresh_file_path.file_name().and_then(|p| p.to_str()).unwrap(); + assert!(file_name.ends_with(".rs"), "Expected rust file"); + + let existing_bindings_file = module.join(file_name); + + if !existing_bindings_file.is_file() { + // file does not already exist + return false + } + + // read the existing file + let existing_contract_bindings = fs::read_to_string(existing_bindings_file).unwrap(); + + let fresh_bindings = fs::read_to_string(fresh_file.path()).unwrap(); + + if existing_contract_bindings != fresh_bindings { + return false + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_generate_multi_abi() { + let crate_root = std::path::Path::new(&env!("CARGO_MANIFEST_DIR")); + + let tempdir = tempfile::tempdir().unwrap(); + let mod_root = tempdir.path().join("contracts"); + + let console = Abigen::new( + "Console", + crate_root.join("../tests/solidity-contracts/console.json").display().to_string(), + ) + .unwrap(); + + let simple_storage = Abigen::new( + "SimpleStorage", + crate_root + .join("../tests/solidity-contracts/simplestorage_abi.json") + .display() + .to_string(), + ) + .unwrap(); + + let human_readable = Abigen::new( + "HrContract", + r#"[ + struct Foo { uint256 x; } + function foo(Foo memory x) + function bar(uint256 x, uint256 y, address addr) + yeet(uint256,uint256,address) + ]"#, + ) + .unwrap(); + + let mut multi_gen = MultiAbigen::from_abigen([console, simple_storage, human_readable]); + + multi_gen.clone().write_to_module(&mod_root).unwrap(); + assert!(multi_gen.clone().ensure_consistent_bindings(&mod_root)); + + // add another contract + multi_gen.abigens.push( + Abigen::new( + "AdditionalContract", + r#"[ + getValue() (uint256) + getValue(uint256 otherValue) (uint256) + getValue(uint256 otherValue, address addr) (uint256) + ]"#, + ) + .unwrap(), + ); + + // ensure inconsistent bindings are detected + assert!(!multi_gen.clone().ensure_consistent_bindings(&mod_root)); + + // update with new contract + multi_gen.clone().write_to_module(&mod_root).unwrap(); + assert!(multi_gen.clone().ensure_consistent_bindings(&mod_root)); + } +}