Repository: gakonst/ethers-rs
Pull request #852 (2022-02-01 19:41:39)

refactor: MultiAbigen rework
Draft status until I update docs and changelog

## Motivation

- allow distinguishing between binding generation and serialization/write failures by introducing an explicit `build` step
- allow lib generation
- allow building bindings in memory to prevent unnecessary disk usage on `ensure_consistent` 


## Solution

- refactor of `MultiAbigen`.
- `MultiAbigen` is now a thin vec wrapper that must be explicitly built into a `MultiBindings` (rather than implicit while writing)
- `MultiBindings` holds a map of `contract name -> ContractBinding`
- `MultiBindings` generates file contents in memory, and makes minimal disk writes
- `MultiBindings` no longer writes a tmp dir when running its checks
- `Multibindings::ensure_consistent_crate` and `::ensure_consistent_module` are now available outside of `#[cfg(test)]` as they no longer use a tmp dir


## PR Checklist

- [x] Added Tests
- [x] Added Documentation
- [x] Updated the changelog


prestwich: Just added some additional convenience:

- `impl FromIterator for MultiAbigen`
- `Abigen::from_file`

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2b6255e..1b0bc972 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,9 @@
 - Move `fill_transaction` implementation to the provider, to allow middleware
   to properly override its behavior.
 - Add informational messages to solc installation and compilation.
+- Significantly refactor `MultiAbigen` module generation. Now allows for lib
+  generation, and does not make unnecessary disk writes.
+  [#854](https://github.com/gakonst/ethers-rs/pull/852)
 
 ## ethers-contract-abigen
 
@@ -105,6 +108,7 @@
   [640](https://github.com/gakonst/ethers-rs/pull/640)
 
 ### Unreleased
+
 - Add support for basic and bearer authentication in http and non-wasm websockets.
   [829](https://github.com/gakonst/ethers-rs/pull/829)
 
diff --git a/ethers-contract/ethers-contract-abigen/src/lib.rs b/ethers-contract/ethers-contract-abigen/src/lib.rs
index 5df72810..85a4abd4 100644
--- a/ethers-contract/ethers-contract-abigen/src/lib.rs
+++ b/ethers-contract/ethers-contract-abigen/src/lib.rs
@@ -19,6 +19,9 @@ mod rustfmt;
 mod source;
 mod util;
 
+pub mod multi;
+pub use multi::MultiAbigen;
+
 pub use ethers_core::types::Address;
 pub use source::Source;
 pub use util::parse_address;
@@ -26,12 +29,7 @@ pub use util::parse_address;
 use anyhow::Result;
 use inflector::Inflector;
 use proc_macro2::TokenStream;
-use std::{
-    collections::HashMap,
-    fs::{self, File},
-    io::Write,
-    path::Path,
-};
+use std::{collections::HashMap, fs::File, io::Write, path::Path};
 
 /// Builder struct for generating type-safe bindings from a contract's ABI
 ///
@@ -86,6 +84,19 @@ impl Abigen {
         })
     }
 
+    /// Attemtps to load a new builder from an ABI JSON file at the specific
+    /// path.
+    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
+        let name = path
+            .as_ref()
+            .file_stem()
+            .ok_or_else(|| anyhow::format_err!("Missing file stem in path"))?
+            .to_str()
+            .ok_or_else(|| anyhow::format_err!("Unable to convert file stem to string"))?;
+
+        Self::new(name, std::fs::read_to_string(path.as_ref())?)
+    }
+
     /// Manually adds a solidity event alias to specify what the event struct
     /// and function name will be in Rust.
     #[must_use]
@@ -138,8 +149,9 @@ impl Abigen {
     /// Generates the contract bindings.
     pub fn generate(self) -> Result<ContractBindings> {
         let rustfmt = self.rustfmt;
+        let name = self.contract_name.clone();
         let tokens = Context::from_abigen(self)?.expand()?.into_tokens();
-        Ok(ContractBindings { tokens, rustfmt })
+        Ok(ContractBindings { tokens, rustfmt, name })
     }
 }
 
@@ -150,6 +162,8 @@ pub struct ContractBindings {
     tokens: TokenStream,
     /// The output options used for serialization.
     rustfmt: bool,
+    /// The contract name
+    name: String,
 }
 
 impl ContractBindings {
@@ -172,6 +186,13 @@ impl ContractBindings {
         Ok(())
     }
 
+    /// Writes the bindings to a new Vec. Panics if unable to allocate
+    pub fn to_vec(&self) -> Vec<u8> {
+        let mut bindings = vec![];
+        self.write(&mut bindings).expect("allocations don't fail");
+        bindings
+    }
+
     /// Writes the bindings to the specified file.
     pub fn write_to_file<P>(&self, path: P) -> Result<()>
     where
@@ -181,289 +202,32 @@ impl ContractBindings {
         self.write(file)
     }
 
-    /// Converts the bindings into its underlying token stream. This allows it
-    /// to be used within a procedural macro.
-    pub fn into_tokens(self) -> TokenStream {
-        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<Abigen>,
-}
-
-impl MultiAbigen {
-    /// Create a new instance from a series of already resolved `Abigen`
-    pub fn from_abigen(abis: impl IntoIterator<Item = Abigen>) -> 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<I, Name, Source>(abis: I) -> Result<Self>
+    /// Writes the bindings to a `contract_name.rs` file in the specified
+    /// directory. The filename is the snake_case transformation of the contract
+    /// name.
+    pub fn write_module_in_dir<P>(&self, dir: P) -> Result<()>
     where
-        I: IntoIterator<Item = (Name, Source)>,
-        Name: AsRef<str>,
-        Source: AsRef<str>,
+        P: AsRef<Path>,
     {
-        let abis = abis
-            .into_iter()
-            .map(|(contract_name, abi_source)| Abigen::new(contract_name.as_ref(), abi_source))
-            .collect::<Result<Vec<_>>>()?;
-
-        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<Path>) -> Result<Self> {
-        let mut abis = Vec::new();
-        for file in util::json_files(dir) {
-            if let Some(file_name) = file.file_stem().and_then(|s| s.to_str()) {
-                let content = fs::read_to_string(&file)?;
-                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
+        let file = dir.as_ref().join(self.module_filename());
+        self.write_to_file(file)
     }
 
-    /// 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<Path>) -> 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(())
+    /// Converts the bindings into its underlying token stream. This allows it
+    /// to be used within a procedural macro.
+    pub fn into_tokens(self) -> TokenStream {
+        self.tokens
     }
 
-    /// 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<Path>) -> 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
+    /// Generate the default module name (snake case of the contract name)
+    pub fn module_name(&self) -> String {
+        self.name.to_snake_case()
     }
-}
-
-#[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));
+    /// Generate the default filename of the module
+    pub fn module_filename(&self) -> String {
+        let mut name = self.module_name();
+        name.extend([".rs"]);
+        name
     }
 }
diff --git a/ethers-contract/ethers-contract-abigen/src/multi.rs b/ethers-contract/ethers-contract-abigen/src/multi.rs
new file mode 100644
index 00000000..19446d61
--- /dev/null
+++ b/ethers-contract/ethers-contract-abigen/src/multi.rs
@@ -0,0 +1,754 @@
+//! TODO
+
+use anyhow::Result;
+use inflector::Inflector;
+use std::{collections::BTreeMap, fs, io::Write, path::Path};
+
+use crate::{util, Abigen, ContractBindings};
+
+/// Collects Abigen structs for a series of contracts, pending generation of
+/// the contract bindings.
+#[derive(Debug, Clone)]
+pub struct MultiAbigen {
+    /// Abigen objects to be written
+    abigens: Vec<Abigen>,
+}
+
+impl std::ops::Deref for MultiAbigen {
+    type Target = Vec<Abigen>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.abigens
+    }
+}
+
+impl From<Vec<Abigen>> for MultiAbigen {
+    fn from(abigens: Vec<Abigen>) -> Self {
+        Self { abigens }
+    }
+}
+
+impl std::iter::FromIterator<Abigen> for MultiAbigen {
+    fn from_iter<I: IntoIterator<Item = Abigen>>(iter: I) -> Self {
+        iter.into_iter().collect::<Vec<_>>().into()
+    }
+}
+
+impl MultiAbigen {
+    /// Create a new instance from a series (`contract name`, `abi_source`)
+    ///
+    /// See `Abigen::new`
+    pub fn new<I, Name, Source>(abis: I) -> Result<Self>
+    where
+        I: IntoIterator<Item = (Name, Source)>,
+        Name: AsRef<str>,
+        Source: AsRef<str>,
+    {
+        let abis = abis
+            .into_iter()
+            .map(|(contract_name, abi_source)| Abigen::new(contract_name.as_ref(), abi_source))
+            .collect::<Result<Vec<_>>>()?;
+
+        Ok(Self::from_abigens(abis))
+    }
+
+    /// Create a new instance from a series of already resolved `Abigen`
+    pub fn from_abigens(abis: impl IntoIterator<Item = Abigen>) -> Self {
+        abis.into_iter().collect()
+    }
+
+    /// 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(root: impl AsRef<Path>) -> Result<Self> {
+        util::json_files(root.as_ref()).into_iter().map(Abigen::from_file).collect()
+    }
+
+    /// Add another Abigen to the module or lib
+    pub fn push(&mut self, abigen: Abigen) {
+        self.abigens.push(abigen)
+    }
+
+    /// Build the contract bindings and prepare for writing
+    pub fn build(self) -> Result<MultiBindings> {
+        let bindings = self
+            .abigens
+            .into_iter()
+            .map(|v| v.generate())
+            .collect::<Result<Vec<_>>>()?
+            .into_iter()
+            .map(|v| (v.name.clone(), v))
+            .collect();
+
+        Ok(MultiBindings { bindings })
+    }
+}
+
+/// Output of the [`MultiAbigen`] build process. `MultiBindings` wraps a group
+/// of built contract bindings that have yet to be written to disk.
+///
+/// `MultiBindings` enables the user to
+/// 1. Write a collection of bindings to a rust module
+/// 2. Write a collection of bindings to a rust lib
+/// 3. Ensure that a collection of bindings matches an on-disk module or lib.
+///
+/// Generally we recommend writing the bindings to a module folder within your
+/// rust project. Users seeking to create "official" bindings for some project
+/// may instead write an entire library to publish via crates.io.
+///
+/// Rather than using `MultiAbigen` in a build script, we recommend committing
+/// the generated files, and replacing the build script with an integration
+/// test. To enable this, we have provided
+/// `MultiBindings::ensure_consistent_bindings` and
+/// `MultiBindings::ensure_consistent_crate`. These functions generate the
+/// expected module or library in memory, and check that the on-disk files
+/// match the expected files. We recommend running these inside CI.
+///
+/// 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)
+pub struct MultiBindings {
+    /// Abigen objects to be written
+    bindings: BTreeMap<String, ContractBindings>,
+}
+
+// deref allows for inspection without modification
+impl std::ops::Deref for MultiBindings {
+    type Target = BTreeMap<String, ContractBindings>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.bindings
+    }
+}
+
+impl MultiBindings {
+    /// Generat the contents of the `Cargo.toml` file for a lib
+    fn generate_cargo_toml(
+        &self,
+        name: impl AsRef<str>,
+        version: impl AsRef<str>,
+    ) -> Result<Vec<u8>> {
+        let mut toml = vec![];
+
+        writeln!(toml, "[package]")?;
+        writeln!(toml, r#"name = "{}""#, name.as_ref())?;
+        writeln!(toml, r#"version = "{}""#, version.as_ref())?;
+        writeln!(toml, r#"edition = "2021""#)?;
+        writeln!(toml)?;
+        writeln!(toml, "# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html")?;
+        writeln!(toml)?;
+        writeln!(toml, "[dependencies]")?;
+        writeln!(
+            toml,
+            r#"ethers = {{ git = "https://github.com/gakonst/ethers-rs", default-features = false }}"#
+        )?;
+        Ok(toml)
+    }
+
+    /// Write the contents of `Cargo.toml` to disk
+    fn write_cargo_toml(
+        &self,
+        lib: &Path,
+        name: impl AsRef<str>,
+        version: impl AsRef<str>,
+    ) -> Result<()> {
+        let contents = self.generate_cargo_toml(name, version)?;
+
+        let mut file = fs::OpenOptions::new()
+            .read(true)
+            .write(true)
+            .create_new(true)
+            .open(lib.join("Cargo.toml"))?;
+        file.write_all(&contents)?;
+
+        Ok(())
+    }
+
+    /// Generate the shared prefix of the `lib.rs` or `mod.rs`
+    fn generate_prefix(
+        &self,
+        mut buf: impl Write,
+        is_crate: bool,
+        single_file: bool,
+    ) -> Result<()> {
+        writeln!(buf, "#![allow(clippy::all)]")?;
+        writeln!(
+            buf,
+            "//! This {} contains abigen! generated bindings for solidity contracts.",
+            if is_crate { "lib" } else { "module" }
+        )?;
+        writeln!(buf, "//! This is autogenerated code.")?;
+        writeln!(buf, "//! Do not manually edit these files.")?;
+        writeln!(
+            buf,
+            "//! {} may be overwritten by the codegen system at any time.",
+            if single_file && !is_crate { "This file" } else { "These files" }
+        )?;
+
+        Ok(())
+    }
+
+    /// Append module declarations to the `lib.rs` or `mod.rs`
+    fn append_module_names(&self, mut buf: impl Write) -> Result<()> {
+        // sorting here not necessary, as btreemap keys are ordered
+        for module in self.bindings.keys().map(|name| format!("pub mod {};", name.to_snake_case()))
+        {
+            writeln!(buf, "{}", module)?;
+        }
+
+        Ok(())
+    }
+
+    /// Generate the contents of `lib.rs` or `mod.rs`
+    fn generate_super_contents(&self, is_crate: bool, single_file: bool) -> Result<Vec<u8>> {
+        let mut contents = vec![];
+        self.generate_prefix(&mut contents, is_crate, single_file)?;
+
+        if !single_file {
+            self.append_module_names(&mut contents)?;
+        } else {
+            for binding in self.bindings.values() {
+                binding.write(&mut contents)?;
+            }
+        }
+
+        Ok(contents)
+    }
+
+    /// Write the `lib.rs` or `mod.rs` to disk
+    fn write_super_file(&self, path: &Path, is_crate: bool, single_file: bool) -> Result<()> {
+        let filename = if is_crate { "lib.rs" } else { "mod.rs" };
+        let contents = self.generate_super_contents(is_crate, single_file)?;
+        fs::write(path.join(filename), contents)?;
+        Ok(())
+    }
+
+    /// Write all contract bindings to their respective files
+    fn write_bindings(&self, path: &Path) -> Result<()> {
+        for binding in self.bindings.values() {
+            binding.write_module_in_dir(path)?;
+        }
+        Ok(())
+    }
+
+    /// 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();
+    /// let bindings = gen.build().unwrap();
+    /// bindings.write_to_module("./src/contracts", false).unwrap();
+    /// ```
+    pub fn write_to_module(self, module: impl AsRef<Path>, single_file: bool) -> Result<()> {
+        let module = module.as_ref();
+        fs::create_dir_all(module)?;
+
+        self.write_super_file(module, false, single_file)?;
+
+        if !single_file {
+            self.write_bindings(module)?;
+        }
+        Ok(())
+    }
+
+    /// Generates all the bindings and writes a library crate containing them
+    /// to the provided path
+    ///
+    /// # Example
+    ///
+    /// Read all json abi files from the `./abi` directory
+    /// ```text
+    /// abi
+    /// ├── ERC20.json
+    /// ├── Contract1.json
+    /// ├── Contract2.json
+    /// ├── Contract3/
+    ///     ├── Contract3.json
+    /// ...
+    /// ```
+    ///
+    /// and write them to the `./bindings` location as
+    ///
+    /// ```text
+    /// bindings
+    /// ├── Cargo.toml
+    /// ├── src/
+    ///     ├── lib.rs
+    ///     ├── er20.rs
+    ///     ├── contract1.rs
+    ///     ├── contract2.rs
+    /// ...
+    /// ```
+    ///
+    /// ```no_run
+    /// # use ethers_contract_abigen::MultiAbigen;
+    /// let gen = MultiAbigen::from_json_files("./abi").unwrap();
+    /// let bindings = gen.build().unwrap();
+    /// bindings.write_to_crate(
+    ///     "my-crate", "0.0.5", "./bindings", false
+    /// ).unwrap();
+    /// ```
+    pub fn write_to_crate(
+        self,
+        name: impl AsRef<str>,
+        version: impl AsRef<str>,
+        lib: impl AsRef<Path>,
+        single_file: bool,
+    ) -> Result<()> {
+        let lib = lib.as_ref();
+        let src = lib.join("src");
+        fs::create_dir_all(&src)?;
+
+        self.write_cargo_toml(lib, name, version)?;
+        self.write_super_file(&src, true, single_file)?;
+
+        if !single_file {
+            self.write_bindings(&src)?;
+        }
+
+        Ok(())
+    }
+
+    /// Ensures the contents of the bindings directory are correct
+    ///
+    /// Does this by first generating the `lib.rs` or `mod.rs`, then the
+    /// contents of each binding file in turn.
+    fn ensure_consistent_bindings(
+        self,
+        dir: impl AsRef<Path>,
+        is_crate: bool,
+        single_file: bool,
+    ) -> Result<()> {
+        let dir = dir.as_ref();
+        let super_name = if is_crate { "lib.rs" } else { "mod.rs" };
+
+        let super_contents = self.generate_super_contents(is_crate, single_file)?;
+        check_file_in_dir(dir, super_name, &super_contents)?;
+
+        // If it is single file, we skip checking anything but the super
+        // contents
+        if !single_file {
+            for binding in self.bindings.values() {
+                check_binding_in_dir(dir, binding)?;
+            }
+        }
+
+        Ok(())
+    }
+
+    /// This ensures that the already generated bindings crate matches 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.
+    ///
+    /// If this functions is run within a test during CI and fails, then it's
+    /// time to update all bindings.
+    ///
+    /// # Returns
+    ///
+    /// `Ok(())` if the freshly generated bindings match with the
+    /// existing bindings. Otherwise an `Err(_)` containing an `anyhow::Report`
+    /// with more information.
+    ///
+    /// # 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();
+    ///  let bindings = gen.build().unwrap();
+    ///  bindings.ensure_consistent_crate(
+    ///     "my-crate", "0.0.1", project_root.join("src/contracts"), false
+    ///  ).expect("inconsistent bindings");
+    /// }
+    /// ```
+    pub fn ensure_consistent_crate(
+        self,
+        name: impl AsRef<str>,
+        version: impl AsRef<str>,
+        crate_path: impl AsRef<Path>,
+        single_file: bool,
+    ) -> Result<()> {
+        let crate_path = crate_path.as_ref();
+
+        // additionally check the contents of the cargo
+        let cargo_contents = self.generate_cargo_toml(name, version)?;
+        check_file_in_dir(crate_path, "Cargo.toml", &cargo_contents)?;
+
+        self.ensure_consistent_bindings(crate_path.join("src"), true, single_file)?;
+        Ok(())
+    }
+
+    /// This ensures that the already generated bindings module matches 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.
+    ///
+    /// If this functions is run within a test during CI and fails, then it's
+    /// time to update all bindings.
+    ///
+    /// # Returns
+    ///
+    /// `Ok(())` if the freshly generated bindings match with the
+    /// existing bindings. Otherwise an `Err(_)` containing an `anyhow::Report`
+    /// with more information.
+    ///
+    /// # 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();
+    ///  let bindings = gen.build().unwrap();
+    ///  bindings.ensure_consistent_module(
+    ///     project_root.join("src/contracts"), false
+    ///  ).expect("inconsistent bindings");
+    /// }
+    /// ```
+    pub fn ensure_consistent_module(
+        self,
+        module: impl AsRef<Path>,
+        single_file: bool,
+    ) -> Result<()> {
+        self.ensure_consistent_bindings(module, false, single_file)?;
+        Ok(())
+    }
+}
+
+fn check_file_in_dir(dir: &Path, file_name: &str, expected_contents: &[u8]) -> Result<()> {
+    anyhow::ensure!(dir.is_dir(), "Not a directory: {}", dir.display());
+
+    let file_path = dir.join(file_name);
+    anyhow::ensure!(file_path.is_file(), "Not a file: {}", file_path.display());
+
+    let contents = fs::read(file_path).expect("Unable to read file");
+    anyhow::ensure!(contents == expected_contents, "file contents do not match");
+    Ok(())
+}
+
+fn check_binding_in_dir(dir: &Path, binding: &ContractBindings) -> Result<()> {
+    let name = binding.module_filename();
+    let contents = binding.to_vec();
+
+    check_file_in_dir(dir, &name, &contents)?;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use std::{panic, path::PathBuf};
+
+    struct Context {
+        multi_gen: MultiAbigen,
+        mod_root: PathBuf,
+    }
+
+    fn run_test<T>(test: T)
+    where
+        T: FnOnce(&Context) -> () + panic::UnwindSafe,
+    {
+        let crate_root = std::path::Path::new(&env!("CARGO_MANIFEST_DIR")).to_owned();
+        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 multi_gen = MultiAbigen::from_abigens([console, simple_storage, human_readable]);
+
+        let mod_root = tempfile::tempdir().unwrap().path().join("contracts");
+        let context = Context { multi_gen, mod_root };
+
+        let result = panic::catch_unwind(|| test(&context));
+
+        assert!(result.is_ok())
+    }
+
+    #[test]
+    fn can_generate_multi_file_module() {
+        run_test(|context| {
+            let Context { multi_gen, mod_root } = context;
+
+            let single_file = false;
+
+            multi_gen.clone().build().unwrap().write_to_module(&mod_root, single_file).unwrap();
+            multi_gen
+                .clone()
+                .build()
+                .unwrap()
+                .ensure_consistent_module(&mod_root, single_file)
+                .expect("Inconsistent bindings");
+        })
+    }
+
+    #[test]
+    fn can_generate_single_file_module() {
+        run_test(|context| {
+            let Context { multi_gen, mod_root } = context;
+
+            let single_file = true;
+
+            multi_gen.clone().build().unwrap().write_to_module(&mod_root, single_file).unwrap();
+            multi_gen
+                .clone()
+                .build()
+                .unwrap()
+                .ensure_consistent_module(&mod_root, single_file)
+                .expect("Inconsistent bindings");
+        })
+    }
+
+    #[test]
+    fn can_generate_multi_file_crate() {
+        run_test(|context| {
+            let Context { multi_gen, mod_root } = context;
+
+            let single_file = false;
+            let name = "a-name";
+            let version = "290.3782.3";
+
+            multi_gen
+                .clone()
+                .build()
+                .unwrap()
+                .write_to_crate(name, version, &mod_root, single_file)
+                .unwrap();
+            multi_gen
+                .clone()
+                .build()
+                .unwrap()
+                .ensure_consistent_crate(name, version, &mod_root, single_file)
+                .expect("Inconsistent bindings");
+        })
+    }
+
+    #[test]
+    fn can_generate_single_file_crate() {
+        run_test(|context| {
+            let Context { multi_gen, mod_root } = context;
+
+            let single_file = true;
+            let name = "a-name";
+            let version = "290.3782.3";
+
+            multi_gen
+                .clone()
+                .build()
+                .unwrap()
+                .write_to_crate(name, version, &mod_root, single_file)
+                .unwrap();
+            multi_gen
+                .clone()
+                .build()
+                .unwrap()
+                .ensure_consistent_crate(name, version, &mod_root, single_file)
+                .expect("Inconsistent bindings");
+        })
+    }
+
+    #[test]
+    fn can_detect_incosistent_multi_file_module() {
+        run_test(|context| {
+            let Context { multi_gen, mod_root } = context;
+
+            let single_file = false;
+
+            multi_gen.clone().build().unwrap().write_to_module(&mod_root, single_file).unwrap();
+
+            let mut cloned = multi_gen.clone();
+            cloned.push(
+                Abigen::new(
+                    "AdditionalContract",
+                    r#"[
+                        getValue() (uint256)
+                    ]"#,
+                )
+                .unwrap(),
+            );
+
+            let result =
+                cloned.build().unwrap().ensure_consistent_module(&mod_root, single_file).is_err();
+
+            // ensure inconsistent bindings are detected
+            assert!(result, "Inconsistent bindings wrongly approved");
+        })
+    }
+
+    #[test]
+    fn can_detect_incosistent_single_file_module() {
+        run_test(|context| {
+            let Context { multi_gen, mod_root } = context;
+
+            let single_file = true;
+
+            multi_gen.clone().build().unwrap().write_to_module(&mod_root, single_file).unwrap();
+
+            let mut cloned = multi_gen.clone();
+            cloned.push(
+                Abigen::new(
+                    "AdditionalContract",
+                    r#"[
+                        getValue() (uint256)
+                    ]"#,
+                )
+                .unwrap(),
+            );
+
+            let result =
+                cloned.build().unwrap().ensure_consistent_module(&mod_root, single_file).is_err();
+
+            // ensure inconsistent bindings are detected
+            assert!(result, "Inconsistent bindings wrongly approved");
+        })
+    }
+
+    #[test]
+    fn can_detect_incosistent_multi_file_crate() {
+        run_test(|context| {
+            let Context { multi_gen, mod_root } = context;
+
+            let single_file = false;
+            let name = "a-name";
+            let version = "290.3782.3";
+
+            multi_gen
+                .clone()
+                .build()
+                .unwrap()
+                .write_to_crate(name, version, &mod_root, single_file)
+                .unwrap();
+
+            let mut cloned = multi_gen.clone();
+            cloned.push(
+                Abigen::new(
+                    "AdditionalContract",
+                    r#"[
+                            getValue() (uint256)
+                        ]"#,
+                )
+                .unwrap(),
+            );
+
+            let result = cloned
+                .build()
+                .unwrap()
+                .ensure_consistent_crate(name, version, &mod_root, single_file)
+                .is_err();
+
+            // ensure inconsistent bindings are detected
+            assert!(result, "Inconsistent bindings wrongly approved");
+        })
+    }
+
+    #[test]
+    fn can_detect_incosistent_single_file_crate() {
+        run_test(|context| {
+            let Context { multi_gen, mod_root } = context;
+
+            let single_file = true;
+            let name = "a-name";
+            let version = "290.3782.3";
+
+            multi_gen
+                .clone()
+                .build()
+                .unwrap()
+                .write_to_crate(name, version, &mod_root, single_file)
+                .unwrap();
+
+            let mut cloned = multi_gen.clone();
+            cloned.push(
+                Abigen::new(
+                    "AdditionalContract",
+                    r#"[
+                            getValue() (uint256)
+                        ]"#,
+                )
+                .unwrap(),
+            );
+
+            let result = cloned
+                .build()
+                .unwrap()
+                .ensure_consistent_crate(name, version, &mod_root, single_file)
+                .is_err();
+
+            // ensure inconsistent bindings are detected
+            assert!(result, "Inconsistent bindings wrongly approved");
+        })
+    }
+}
diff --git a/ethers-core/src/abi/codec.rs b/ethers-core/src/abi/codec.rs
index f4de400c..fef30f18 100644
--- a/ethers-core/src/abi/codec.rs
+++ b/ethers-core/src/abi/codec.rs
@@ -2,7 +2,7 @@ use crate::{
     abi::{
         AbiArrayType, AbiError, AbiType, Detokenize, Token, Tokenizable, TokenizableItem, Tokenize,
     },
-    types::{Address, H256, U128, U256},
+    types::{Address, Bytes, H256, U128, U256},
 };
 
 /// Trait for ABI encoding
@@ -40,6 +40,7 @@ macro_rules! impl_abi_codec {
 
 impl_abi_codec!(
     Vec<u8>,
+    Bytes,
     Address,
     bool,
     String,
@@ -228,4 +229,11 @@ mod tests {
         .collect::<String>()
         };
     }
+
+    #[test]
+    fn bytes_codec() {
+        let bytes: Bytes = std::iter::repeat_with(random::<u8>).take(10).collect::<Vec<_>>().into();
+        let v = vec![bytes];
+        assert_codec(v);
+    }
 }


Test patch:
diff --git a/ethers-contract/tests/common/derive.rs b/ethers-contract/tests/common/derive.rs
index 58c0aa83..a18879d3 100644
--- a/ethers-contract/tests/common/derive.rs
+++ b/ethers-contract/tests/common/derive.rs
@@ -3,7 +3,7 @@ use ethers_contract::{
 };
 use ethers_core::{
     abi::{AbiDecode, AbiEncode, RawLog, Tokenizable},
-    types::{Address, H160, H256, I256, U128, U256},
+    types::{Address, Bytes, H160, H256, I256, U128, U256},
 };
 
 fn assert_tokenizeable<T: Tokenizable>() {}
@@ -560,3 +560,15 @@ fn can_derive_abi_codec_two_field() {
 
     assert_eq!(decoded_wrapped, tuple);
 }
+
+#[test]
+fn can_derive_ethcall_for_bytes() {
+    #[derive(Clone, Debug, Default, Eq, PartialEq, EthCall, EthDisplay)]
+    #[ethcall(name = "batch", abi = "batch(bytes[],bool)")]
+    pub struct BatchCall {
+        pub calls: Vec<Bytes>,
+        pub revert_on_fail: bool,
+    }
+
+    assert_ethcall::<BatchCall>();
+}