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}