ethcontract_generate/
source.rs

1//! Allows loading serialized artifacts from various sources.
2//!
3//! This module does not provide means for parsing artifacts. For that,
4//! use facilities in [`ethcontract_common::artifact`].
5//!
6//! # Examples
7//!
8//! Load artifact from local file:
9//!
10//! ```no_run
11//! # use ethcontract_generate::Source;
12//! let json = Source::local("build/contracts/IERC20.json")
13//!     .artifact_json()
14//!     .expect("failed to load an artifact");
15//! ```
16//!
17//! Load artifact from an NPM package:
18//!
19//! ```no_run
20//! # use ethcontract_generate::Source;
21//! let json = Source::npm("npm:@openzeppelin/contracts@2.5.0/build/contracts/IERC20.json")
22//!     .artifact_json()
23//!     .expect("failed to load an artifact");
24//! ```
25
26#[cfg(feature = "http")]
27use crate::util;
28use anyhow::{anyhow, Context, Error, Result};
29#[cfg(feature = "http")]
30use ethcontract_common::Address;
31use std::borrow::Cow;
32use std::env;
33use std::fs;
34use std::path::{Path, PathBuf};
35use std::str::FromStr;
36use url::Url;
37
38/// A source of an artifact JSON.
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub enum Source {
41    /// File on the local file system.
42    Local(PathBuf),
43
44    /// Resource in the internet, available via HTTP(S).
45    #[cfg(feature = "http")]
46    Http(Url),
47
48    /// An address of a mainnet contract, available via [Etherscan].
49    ///
50    /// Artifacts loaded from etherstan can be parsed using
51    /// the [truffle loader].
52    ///
53    /// Note that Etherscan rate-limits requests to their API, to avoid this,
54    /// provide an Etherscan API key via the `ETHERSCAN_API_KEY`
55    /// environment variable.
56    ///
57    /// [Etherscan]: etherscan.io
58    /// [truffle loader]: ethcontract_common::artifact::truffle::TruffleLoader
59    #[cfg(feature = "http")]
60    Etherscan(Address),
61
62    /// The package identifier of an NPM package with a path to an artifact
63    /// or ABI to be retrieved from [unpkg].
64    ///
65    /// [unpkg]: unpkg.io
66    #[cfg(feature = "http")]
67    Npm(String),
68}
69
70impl Source {
71    /// Parses an artifact source from a string.
72    ///
73    /// This method accepts the following:
74    ///
75    /// - relative path to a contract JSON file on the local filesystem,
76    ///   for example `build/IERC20.json`. This relative path is rooted
77    ///   in the current working directory. To specify the root for relative
78    ///   paths, use [`with_root`] function;
79    ///
80    /// - absolute path to a contract JSON file on the local filesystem,
81    ///   or a file URL, for example `/build/IERC20.json`, or the same path
82    ///   using URL: `file:///build/IERC20.json`;
83    ///
84    /// - an HTTP(S) URL pointing to artifact JSON or contract ABI JSON;
85    ///
86    /// - a URL with `etherscan` scheme and a mainnet contract address.
87    ///   For example `etherscan:0xC02AA...`. Alternatively, specify
88    ///   an [etherscan] URL: `https://etherscan.io/address/0xC02AA...`.
89    ///   The contract artifact or ABI will be retrieved through [`Etherscan`];
90    ///
91    /// - a URL with `npm` scheme, NPM package name, an optional version
92    ///   and a path (defaulting to the latest version and `index.js`).
93    ///   For example `npm:@openzeppelin/contracts/build/contracts/IERC20.json`.
94    ///   The contract artifact or ABI will be retrieved through [`unpkg`].
95    ///
96    /// [Etherscan]: etherscan.io
97    /// [unpkg]: unpkg.io
98    pub fn parse(source: &str) -> Result<Self> {
99        let root = env::current_dir()?.canonicalize()?;
100        Source::with_root(root, source)
101    }
102
103    /// Parses an artifact source from a string and uses the specified root
104    /// directory for resolving relative paths. See [`parse`] for more details
105    /// on supported source strings.
106    pub fn with_root(root: impl AsRef<Path>, source: &str) -> Result<Self> {
107        let root = root.as_ref();
108        let base = Url::from_directory_path(root)
109            .map_err(|_| anyhow!("root path '{}' is not absolute", root.display()))?;
110        let url = base.join(source.as_ref())?;
111
112        match url.scheme() {
113            "file" => Ok(Source::local(root.join(source))),
114            #[cfg(feature = "http")]
115            "http" | "https" => match url.host_str() {
116                Some("etherscan.io") => Source::etherscan(
117                    url.path()
118                        .rsplit('/')
119                        .next()
120                        .ok_or_else(|| anyhow!("HTTP URL does not have a path"))?,
121                ),
122                _ => Ok(Source::Http(url)),
123            },
124            #[cfg(feature = "http")]
125            "etherscan" => Source::etherscan(url.path()),
126            #[cfg(feature = "http")]
127            "npm" => Ok(Source::npm(url.path())),
128            _ => Err(anyhow!("unsupported URL '{}'", url)),
129        }
130    }
131
132    /// Creates a local filesystem source from a path string.
133    pub fn local(path: impl AsRef<Path>) -> Self {
134        Source::Local(path.as_ref().into())
135    }
136
137    /// Creates an HTTP source from a URL.
138    #[cfg(feature = "http")]
139    pub fn http(url: &str) -> Result<Self> {
140        Ok(Source::Http(Url::parse(url)?))
141    }
142
143    /// Creates an [Etherscan] source from contract address on mainnet.
144    ///
145    /// [Etherscan]: etherscan.io
146    #[cfg(feature = "http")]
147    pub fn etherscan(address: &str) -> Result<Self> {
148        util::parse_address(address)
149            .context("failed to parse address for Etherscan source")
150            .map(Source::Etherscan)
151    }
152
153    /// Creates an NPM source from a package path.
154    #[cfg(feature = "http")]
155    pub fn npm(package_path: impl Into<String>) -> Self {
156        Source::Npm(package_path.into())
157    }
158
159    /// Retrieves the source JSON of the artifact.
160    ///
161    /// This will either read the JSON from the file system or retrieve
162    /// a contract ABI from the network, depending on the source type.
163    ///
164    /// Contract ABIs will be wrapped into a JSON object, so that you can load
165    /// them using the [truffle loader].
166    ///
167    /// [truffle loader]: ethcontract_common::artifact::truffle::TruffleLoader
168    pub fn artifact_json(&self) -> Result<String> {
169        match self {
170            Source::Local(path) => get_local_contract(path),
171            #[cfg(feature = "http")]
172            Source::Http(url) => get_http_contract(url),
173            #[cfg(feature = "http")]
174            Source::Etherscan(address) => get_etherscan_contract(*address),
175            #[cfg(feature = "http")]
176            Source::Npm(package) => get_npm_contract(package),
177        }
178    }
179}
180
181impl FromStr for Source {
182    type Err = Error;
183
184    fn from_str(s: &str) -> Result<Self> {
185        Source::parse(s)
186    }
187}
188
189fn get_local_contract(path: &Path) -> Result<String> {
190    let path = if path.is_relative() {
191        let absolute_path = path.canonicalize().with_context(|| {
192            format!(
193                "unable to canonicalize file from working dir {} with path {}",
194                env::current_dir()
195                    .map(|cwd| cwd.display().to_string())
196                    .unwrap_or_else(|err| format!("??? ({})", err)),
197                path.display(),
198            )
199        })?;
200        Cow::Owned(absolute_path)
201    } else {
202        Cow::Borrowed(path)
203    };
204
205    let json = fs::read_to_string(path).context("failed to read artifact JSON file")?;
206    Ok(abi_or_artifact(json))
207}
208
209#[cfg(feature = "http")]
210fn get_http_contract(url: &Url) -> Result<String> {
211    let json = util::http_get(url.as_str())
212        .with_context(|| format!("failed to retrieve JSON from {}", url))?;
213    Ok(abi_or_artifact(json))
214}
215
216#[cfg(feature = "http")]
217fn get_etherscan_contract(address: Address) -> Result<String> {
218    // NOTE: We do not retrieve the bytecode since deploying contracts with the
219    //   same bytecode is unreliable as the libraries have already linked and
220    //   probably don't reference anything when deploying on other networks.
221
222    let api_key = env::var("ETHERSCAN_API_KEY")
223        .map(|key| format!("&apikey={}", key))
224        .unwrap_or_default();
225
226    let abi_url = format!(
227        "http://api.etherscan.io/api\
228         ?module=contract&action=getabi&address={:?}&format=raw{}",
229        address, api_key,
230    );
231    let abi = util::http_get(&abi_url).context("failed to retrieve ABI from Etherscan.io")?;
232
233    // NOTE: Wrap the retrieved ABI in an empty contract, this is because
234    //   currently, the code generation infrastructure depends on having an
235    //   `Artifact` instance.
236    let json = format!(
237        r#"{{"abi":{},"networks":{{"1":{{"address":"{:?}"}}}}}}"#,
238        abi, address,
239    );
240
241    Ok(json)
242}
243
244#[cfg(feature = "http")]
245fn get_npm_contract(package: &str) -> Result<String> {
246    let unpkg_url = format!("https://unpkg.com/{}", package);
247    let json = util::http_get(&unpkg_url)
248        .with_context(|| format!("failed to retrieve JSON from for npm package {}", package))?;
249
250    Ok(abi_or_artifact(json))
251}
252
253/// A best-effort coercion of an ABI or an artifact JSON document into an
254/// artifact JSON document.
255///
256/// This method uses the fact that ABIs are arrays and artifacts are
257/// objects to guess at what type of document this is. Note that no parsing or
258/// validation is done at this point as the document gets parsed and validated
259/// at generation time.
260///
261/// This needs to be done as currently the contract generation infrastructure
262/// depends on having an artifact.
263// TODO(taminomara): add loader for plain ABIs?
264fn abi_or_artifact(json: String) -> String {
265    if json.trim().starts_with('[') {
266        format!(r#"{{"abi":{}}}"#, json.trim())
267    } else {
268        json
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn parse_source() {
278        let root = "/rooted";
279        for (url, expected) in &[
280            (
281                "relative/Contract.json",
282                Source::local("/rooted/relative/Contract.json"),
283            ),
284            (
285                "/absolute/Contract.json",
286                Source::local("/absolute/Contract.json"),
287            ),
288            #[cfg(feature = "http")]
289            (
290                "https://my.domain.eth/path/to/Contract.json",
291                Source::http("https://my.domain.eth/path/to/Contract.json").unwrap(),
292            ),
293            #[cfg(feature = "http")]
294            (
295                "etherscan:0x0001020304050607080910111213141516171819",
296                Source::etherscan("0x0001020304050607080910111213141516171819").unwrap(),
297            ),
298            #[cfg(feature = "http")]
299            (
300                "https://etherscan.io/address/0x0001020304050607080910111213141516171819",
301                Source::etherscan("0x0001020304050607080910111213141516171819").unwrap(),
302            ),
303            #[cfg(feature = "http")]
304            (
305                "npm:@openzeppelin/contracts@2.5.0/build/contracts/IERC20.json",
306                Source::npm("@openzeppelin/contracts@2.5.0/build/contracts/IERC20.json"),
307            ),
308        ] {
309            let source = Source::with_root(root, url).unwrap();
310            assert_eq!(source, *expected);
311        }
312    }
313}