Skip to content
This repository was archived by the owner on Oct 19, 2024. It is now read-only.

Commit c7cf5be

Browse files
authored
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
1 parent bb0cd7a commit c7cf5be

File tree

5 files changed

+320
-21
lines changed

5 files changed

+320
-21
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
## ethers-contract-abigen
2525

26+
- Add `MultiAbigen` to generate a series of contract bindings that can be kept in the repo
27+
[#724](https://github.com/gakonst/ethers-rs/pull/724).
2628
- Add provided `event_derives` to call and event enums as well
2729
[#721](https://github.com/gakonst/ethers-rs/pull/721).
2830
- Implement snowtrace and polygonscan on par with the etherscan integration

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ethers-contract/ethers-contract-abigen/Cargo.toml

+3
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ rustdoc-args = ["--cfg", "docsrs"]
3838
default = ["reqwest", "rustls"]
3939
openssl = ["reqwest/native-tls"]
4040
rustls = ["reqwest/rustls-tls"]
41+
42+
[dev-dependencies]
43+
tempfile = "3.2.0"

ethers-contract/ethers-contract-abigen/src/contract.rs

+23-20
Original file line numberDiff line numberDiff line change
@@ -154,27 +154,8 @@ impl Context {
154154
// get the actual ABI string
155155
let abi_str =
156156
args.abi_source.get().map_err(|e| anyhow!("failed to get ABI JSON: {}", e))?;
157-
let mut abi_parser = AbiParser::default();
158157

159-
let (abi, human_readable): (Abi, _) = if let Ok(abi) = abi_parser.parse_str(&abi_str) {
160-
(abi, true)
161-
} else {
162-
// a best-effort coercion of an ABI or an artifact JSON into an artifact JSON.
163-
let json_abi_str = if abi_str.trim().starts_with('[') {
164-
format!(r#"{{"abi":{}}}"#, abi_str.trim())
165-
} else {
166-
abi_str.clone()
167-
};
168-
169-
#[derive(Deserialize)]
170-
struct Contract {
171-
abi: Abi,
172-
}
173-
174-
let contract = serde_json::from_str::<Contract>(&json_abi_str)?;
175-
176-
(contract.abi, false)
177-
};
158+
let (abi, human_readable, abi_parser) = parse_abi(&abi_str)?;
178159

179160
// try to extract all the solidity structs from the normal JSON ABI
180161
// we need to parse the json abi again because we need the internalType fields which are
@@ -251,3 +232,25 @@ impl Context {
251232
&mut self.internal_structs
252233
}
253234
}
235+
236+
/// Parse the abi via `Source::parse` and return if the abi defined as human readable
237+
fn parse_abi(abi_str: &str) -> Result<(Abi, bool, AbiParser)> {
238+
let mut abi_parser = AbiParser::default();
239+
let res = if let Ok(abi) = abi_parser.parse_str(abi_str) {
240+
(abi, true, abi_parser)
241+
} else {
242+
#[derive(Deserialize)]
243+
struct Contract {
244+
abi: Abi,
245+
}
246+
// a best-effort coercion of an ABI or an artifact JSON into an artifact JSON.
247+
let contract: Contract = if abi_str.trim_start().starts_with('[') {
248+
serde_json::from_str(&format!(r#"{{"abi":{}}}"#, abi_str.trim()))?
249+
} else {
250+
serde_json::from_str::<Contract>(abi_str)?
251+
};
252+
253+
(contract.abi, false, abi_parser)
254+
};
255+
Ok(res)
256+
}

ethers-contract/ethers-contract-abigen/src/lib.rs

+291-1
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ pub use source::Source;
2424
pub use util::parse_address;
2525

2626
use anyhow::Result;
27+
use inflector::Inflector;
2728
use proc_macro2::TokenStream;
28-
use std::{collections::HashMap, fs::File, io::Write, path::Path};
29+
use std::{
30+
collections::HashMap,
31+
fs::{self, File},
32+
io::Write,
33+
path::Path,
34+
};
2935

3036
/// Builder struct for generating type-safe bindings from a contract's ABI
3137
///
@@ -44,6 +50,7 @@ use std::{collections::HashMap, fs::File, io::Write, path::Path};
4450
/// Abigen::new("ERC20Token", "./abi.json")?.generate()?.write_to_file("token.rs")?;
4551
/// # Ok(())
4652
/// # }
53+
#[derive(Debug, Clone)]
4754
pub struct Abigen {
4855
/// The source of the ABI JSON for the contract whose bindings
4956
/// are being generated.
@@ -180,3 +187,286 @@ impl ContractBindings {
180187
self.tokens
181188
}
182189
}
190+
191+
/// Generates bindings for a series of contracts
192+
///
193+
/// This type can be used to generate multiple `ContractBindings` and put them all in a single rust
194+
/// module, (eg. a `contracts` directory).
195+
///
196+
/// This can be used to
197+
/// 1) write all bindings directly into a new directory in the project's source directory, so that
198+
/// it is included in the repository. 2) write all bindings to the value of cargo's `OUT_DIR` in a
199+
/// build script and import the bindings as `include!(concat!(env!("OUT_DIR"), "/mod.rs"));`.
200+
///
201+
/// However, the main purpose of this generator is to create bindings for option `1)` and write all
202+
/// contracts to some `contracts` module in `src`, like `src/contracts/mod.rs` __once__ via a build
203+
/// script or a test. After that it's recommend to remove the build script and replace it with an
204+
/// integration test (See `MultiAbigen::ensure_consistent_bindings`) that fails if the generated
205+
/// code is out of date. This has several advantages:
206+
///
207+
/// * No need for downstream users to compile the build script
208+
/// * No need for downstream users to run the whole `abigen!` generation steps
209+
/// * The generated code is more usable in an IDE
210+
/// * CI will fail if the generated code is out of date (if `abigen!` or the contract's ABI itself
211+
/// changed)
212+
///
213+
/// See `MultiAbigen::ensure_consistent_bindings` for the recommended way to set this up to generate
214+
/// the bindings once via a test and then use the test to ensure consistency.
215+
#[derive(Debug, Clone)]
216+
pub struct MultiAbigen {
217+
/// whether to write all contracts in a single file instead of separated modules
218+
single_file: bool,
219+
220+
abigens: Vec<Abigen>,
221+
}
222+
223+
impl MultiAbigen {
224+
/// Create a new instance from a series of already resolved `Abigen`
225+
pub fn from_abigen(abis: impl IntoIterator<Item = Abigen>) -> Self {
226+
Self {
227+
single_file: false,
228+
abigens: abis.into_iter().map(|abi| abi.rustfmt(true)).collect(),
229+
}
230+
}
231+
232+
/// Create a new instance from a series (`contract name`, `abi_source`)
233+
///
234+
/// See `Abigen::new`
235+
pub fn new<I, Name, Source>(abis: I) -> Result<Self>
236+
where
237+
I: IntoIterator<Item = (Name, Source)>,
238+
Name: AsRef<str>,
239+
Source: AsRef<str>,
240+
{
241+
let abis = abis
242+
.into_iter()
243+
.map(|(contract_name, abi_source)| Abigen::new(contract_name.as_ref(), abi_source))
244+
.collect::<Result<Vec<_>>>()?;
245+
246+
Ok(Self::from_abigen(abis))
247+
}
248+
249+
/// Reads all json files contained in the given `dir` and use the file name for the name of the
250+
/// `ContractBindings`.
251+
/// This is equivalent to calling `MultiAbigen::new` with all the json files and their filename.
252+
///
253+
/// # Example
254+
///
255+
/// ```text
256+
/// abi
257+
/// ├── ERC20.json
258+
/// ├── Contract1.json
259+
/// ├── Contract2.json
260+
/// ...
261+
/// ```
262+
///
263+
/// ```no_run
264+
/// # use ethers_contract_abigen::MultiAbigen;
265+
/// let gen = MultiAbigen::from_json_files("./abi").unwrap();
266+
/// ```
267+
pub fn from_json_files(dir: impl AsRef<Path>) -> Result<Self> {
268+
let mut abis = Vec::new();
269+
for file in fs::read_dir(dir)?.into_iter().filter_map(std::io::Result::ok).filter(|p| {
270+
p.path().is_file() && p.path().extension().and_then(|ext| ext.to_str()) == Some("json")
271+
}) {
272+
let file: fs::DirEntry = file;
273+
if let Some(file_name) = file.path().file_stem().and_then(|s| s.to_str()) {
274+
let content = fs::read_to_string(file.path())?;
275+
abis.push((file_name.to_string(), content));
276+
}
277+
}
278+
Self::new(abis)
279+
}
280+
281+
/// Write all bindings into a single rust file instead of separate modules
282+
#[must_use]
283+
pub fn single_file(mut self) -> Self {
284+
self.single_file = true;
285+
self
286+
}
287+
288+
/// Generates all the bindings and writes them to the given module
289+
///
290+
/// # Example
291+
///
292+
/// Read all json abi files from the `./abi` directory
293+
/// ```text
294+
/// abi
295+
/// ├── ERC20.json
296+
/// ├── Contract1.json
297+
/// ├── Contract2.json
298+
/// ...
299+
/// ```
300+
///
301+
/// and write them to the `./src/contracts` location as
302+
///
303+
/// ```text
304+
/// src/contracts
305+
/// ├── mod.rs
306+
/// ├── er20.rs
307+
/// ├── contract1.rs
308+
/// ├── contract2.rs
309+
/// ...
310+
/// ```
311+
///
312+
/// ```no_run
313+
/// # use ethers_contract_abigen::MultiAbigen;
314+
/// let gen = MultiAbigen::from_json_files("./abi").unwrap();
315+
/// gen.write_to_module("./src/contracts").unwrap();
316+
/// ```
317+
pub fn write_to_module(self, module: impl AsRef<Path>) -> Result<()> {
318+
let module = module.as_ref();
319+
fs::create_dir_all(module)?;
320+
321+
let mut contracts_mod =
322+
b"/// This module contains all the autogenerated abigen! contract bindings\n".to_vec();
323+
324+
let mut modules = Vec::new();
325+
for abi in self.abigens {
326+
let name = abi.contract_name.to_snake_case();
327+
let bindings = abi.generate()?;
328+
if self.single_file {
329+
// append to the mod file
330+
bindings.write(&mut contracts_mod)?;
331+
} else {
332+
// create a contract rust file
333+
let output = module.join(format!("{}.rs", name));
334+
bindings.write_to_file(output)?;
335+
modules.push(format!("pub mod {};", name));
336+
}
337+
}
338+
339+
if !modules.is_empty() {
340+
modules.sort();
341+
write!(contracts_mod, "{}", modules.join("\n"))?;
342+
}
343+
344+
// write the mod file
345+
fs::write(module.join("mod.rs"), contracts_mod)?;
346+
347+
Ok(())
348+
}
349+
350+
/// This ensures that the already generated contract bindings match the output of a fresh new
351+
/// run. Run this in a rust test, to get notified in CI if the newly generated bindings
352+
/// deviate from the already generated ones, and it's time to generate them again. This could
353+
/// happen if the ABI of a contract or the output that `ethers` generates changed.
354+
///
355+
/// So if this functions is run within a test during CI and fails, then it's time to update all
356+
/// bindings.
357+
///
358+
/// Returns `true` if the freshly generated bindings match with the existing bindings, `false`
359+
/// otherwise
360+
///
361+
/// # Example
362+
///
363+
/// Check that the generated files are up to date
364+
///
365+
/// ```no_run
366+
/// # use ethers_contract_abigen::MultiAbigen;
367+
/// #[test]
368+
/// fn generated_bindings_are_fresh() {
369+
/// let project_root = std::path::Path::new(&env!("CARGO_MANIFEST_DIR"));
370+
/// let abi_dir = project_root.join("abi");
371+
/// let gen = MultiAbigen::from_json_files(&abi_dir).unwrap();
372+
/// assert!(gen.ensure_consistent_bindings(project_root.join("src/contracts")));
373+
/// }
374+
///
375+
/// gen.write_to_module("./src/contracts").unwrap();
376+
/// ```
377+
#[cfg(test)]
378+
pub fn ensure_consistent_bindings(self, module: impl AsRef<Path>) -> bool {
379+
let module = module.as_ref();
380+
let dir = tempfile::tempdir().expect("Failed to create temp dir");
381+
let temp_module = dir.path().join("contracts");
382+
self.write_to_module(&temp_module).expect("Failed to generate bindings");
383+
384+
for file in fs::read_dir(&temp_module).unwrap() {
385+
let fresh_file = file.unwrap();
386+
let fresh_file_path = fresh_file.path();
387+
let file_name = fresh_file_path.file_name().and_then(|p| p.to_str()).unwrap();
388+
assert!(file_name.ends_with(".rs"), "Expected rust file");
389+
390+
let existing_bindings_file = module.join(file_name);
391+
392+
if !existing_bindings_file.is_file() {
393+
// file does not already exist
394+
return false
395+
}
396+
397+
// read the existing file
398+
let existing_contract_bindings = fs::read_to_string(existing_bindings_file).unwrap();
399+
400+
let fresh_bindings = fs::read_to_string(fresh_file.path()).unwrap();
401+
402+
if existing_contract_bindings != fresh_bindings {
403+
return false
404+
}
405+
}
406+
true
407+
}
408+
}
409+
410+
#[cfg(test)]
411+
mod tests {
412+
use super::*;
413+
414+
#[test]
415+
fn can_generate_multi_abi() {
416+
let crate_root = std::path::Path::new(&env!("CARGO_MANIFEST_DIR"));
417+
418+
let tempdir = tempfile::tempdir().unwrap();
419+
let mod_root = tempdir.path().join("contracts");
420+
421+
let console = Abigen::new(
422+
"Console",
423+
crate_root.join("../tests/solidity-contracts/console.json").display().to_string(),
424+
)
425+
.unwrap();
426+
427+
let simple_storage = Abigen::new(
428+
"SimpleStorage",
429+
crate_root
430+
.join("../tests/solidity-contracts/simplestorage_abi.json")
431+
.display()
432+
.to_string(),
433+
)
434+
.unwrap();
435+
436+
let human_readable = Abigen::new(
437+
"HrContract",
438+
r#"[
439+
struct Foo { uint256 x; }
440+
function foo(Foo memory x)
441+
function bar(uint256 x, uint256 y, address addr)
442+
yeet(uint256,uint256,address)
443+
]"#,
444+
)
445+
.unwrap();
446+
447+
let mut multi_gen = MultiAbigen::from_abigen([console, simple_storage, human_readable]);
448+
449+
multi_gen.clone().write_to_module(&mod_root).unwrap();
450+
assert!(multi_gen.clone().ensure_consistent_bindings(&mod_root));
451+
452+
// add another contract
453+
multi_gen.abigens.push(
454+
Abigen::new(
455+
"AdditionalContract",
456+
r#"[
457+
getValue() (uint256)
458+
getValue(uint256 otherValue) (uint256)
459+
getValue(uint256 otherValue, address addr) (uint256)
460+
]"#,
461+
)
462+
.unwrap(),
463+
);
464+
465+
// ensure inconsistent bindings are detected
466+
assert!(!multi_gen.clone().ensure_consistent_bindings(&mod_root));
467+
468+
// update with new contract
469+
multi_gen.clone().write_to_module(&mod_root).unwrap();
470+
assert!(multi_gen.clone().ensure_consistent_bindings(&mod_root));
471+
}
472+
}

0 commit comments

Comments
 (0)