diff --git a/Cargo.lock b/Cargo.lock index f7bf2a35..ee57c761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1362,6 +1362,7 @@ dependencies = [ "cfg-if 1.0.0", "dunce", "ethers-core", + "ethers-etherscan", "ethers-solc", "eyre", "getrandom 0.2.8", @@ -1375,6 +1376,7 @@ dependencies = [ "serde_json", "syn", "tempfile", + "tokio", "toml", "url", "walkdir", diff --git a/ethers-contract/ethers-contract-abigen/Cargo.toml b/ethers-contract/ethers-contract-abigen/Cargo.toml index 842eeb20..5f4af839 100644 --- a/ethers-contract/ethers-contract-abigen/Cargo.toml +++ b/ethers-contract/ethers-contract-abigen/Cargo.toml @@ -15,6 +15,7 @@ keywords = ["ethereum", "web3", "celo", "ethers"] [dependencies] ethers-core = { version = "^1.0.0", path = "../../ethers-core", features = ["macros"] } +ethers-etherscan = { path = "../../ethers-etherscan", default-features = false, optional = true } proc-macro2 = "1.0" quote = "1.0" @@ -22,11 +23,9 @@ syn = { version = "1.0.12", default-features = false, features = ["full"] } prettyplease = "0.1.23" Inflector = "0.11" -url = "2.1" serde_json = "1.0.61" serde = { version = "1.0.124", features = ["derive"] } hex = { version = "0.4.2", default-features = false, features = ["std"] } -reqwest = { version = "0.11.3", default-features = false, features = ["blocking"], optional = true } cfg-if = "1.0.0" dunce = "1.0.2" walkdir = "2.3.2" @@ -34,6 +33,13 @@ eyre = "0.6" regex = "1.6.0" toml = "0.5.9" +reqwest = { version = "0.11.3", default-features = false, features = ["blocking"], optional = true } +tokio = { version = "1.0", default-features = false, features = [ + "rt-multi-thread", + "sync", +], optional = true } +url = { version = "2.3.1", default-features = false, optional = true } + [target.'cfg(target_arch = "wasm32")'.dependencies] # NOTE: this enables wasm compatibility for getrandom indirectly getrandom = { version = "0.2", features = ["js"] } @@ -43,9 +49,9 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -default = ["reqwest", "rustls"] -openssl = ["reqwest/native-tls"] -rustls = ["reqwest/rustls-tls"] +online = ["reqwest", "ethers-etherscan", "url", "tokio"] +openssl = ["online", "reqwest/native-tls", "ethers-etherscan/openssl"] +rustls = ["online", "reqwest/rustls-tls", "ethers-etherscan/rustls"] [dev-dependencies] tempfile = "3.2.0" diff --git a/ethers-contract/ethers-contract-abigen/src/lib.rs b/ethers-contract/ethers-contract-abigen/src/lib.rs index 676b42fe..fe395c70 100644 --- a/ethers-contract/ethers-contract-abigen/src/lib.rs +++ b/ethers-contract/ethers-contract-abigen/src/lib.rs @@ -27,9 +27,6 @@ pub use multi::MultiAbigen; mod source; pub use source::Source; -mod util; -pub use util::parse_address; - pub use ethers_core::types::Address; use contract::{Context, ExpandedContract}; diff --git a/ethers-contract/ethers-contract-abigen/src/source.rs b/ethers-contract/ethers-contract-abigen/src/source.rs deleted file mode 100644 index 2cedae73..00000000 --- a/ethers-contract/ethers-contract-abigen/src/source.rs +++ /dev/null @@ -1,400 +0,0 @@ -//! Module implements reading of contract artifacts from various sources. -use super::util; -use ethers_core::types::Address; - -use crate::util::resolve_path; -use cfg_if::cfg_if; -use eyre::{eyre, Context, Error, Result}; -use std::{env, fs, path::Path, str::FromStr}; -use url::Url; - -/// A source of a Truffle artifact JSON. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Source { - /// A raw ABI string - String(String), - - /// An ABI located on the local file system. - Local(String), - - /// An ABI to be retrieved over HTTP(S). - Http(Url), - - /// An address of a mainnet contract that has been verified on Bscscan.com. - Bscscan(Address), - - /// An address of a mainnet contract that has been verified on Etherscan.io. - Etherscan(Address), - - /// An address of a mainnet contract that has been verified on Polygonscan.com. - Polygonscan(Address), - - /// An address of a mainnet contract that has been verified on snowtrace.io. - Snowtrace(Address), - - /// The package identifier of an npm package with a path to a Truffle - /// artifact or ABI to be retrieved from `unpkg.io`. - Npm(String), -} - -impl Source { - /// Parses an ABI from a source - /// - /// Contract ABIs can be retrieved from the local filesystem or online - /// from `etherscan.io`. They can also be provided in-line. This method parses - /// ABI source URLs and accepts the following: - /// - /// - raw ABI JSON - /// - /// - `relative/path/to/Contract.json`: a relative path to an ABI JSON file. - /// This relative path is rooted in the current working directory. - /// To specify the root for relative paths, use `Source::with_root`. - /// - /// - `/absolute/path/to/Contract.json` or `file:///absolute/path/to/Contract.json`: an absolute - /// path or file URL to an ABI JSON file. - /// - /// - `http(s)://...` an HTTP url to a contract ABI. - /// - /// - `etherscan:0xXX..XX` or `https://etherscan.io/address/0xXX..XX`: a address or URL of a - /// verified contract on Etherscan. - /// - `bscscan:0xXX..XX` or `https://bscscan.io/address/0xXX..XX`: a address or URL of a - /// verified contract on Bscscan. - /// - `polygonscan:0xXX..XX` or `https://polygonscan.io/address/0xXX..XX`: a address or URL of a - /// verified contract on Polygonscan. - /// - `snowtrace:0xXX..XX` or `https://snowtrace.io/address/0xXX..XX`: a address or URL of a - /// verified contract on Snowtrace. - /// - `npm:@org/package@1.0.0/path/to/contract.json` an npmjs package with an optional version - /// and path (defaulting to the latest version and `index.js`). The contract ABI will be - /// retrieved through `unpkg.io`. - pub fn parse(source: S) -> Result - where - S: AsRef, - { - let source = source.as_ref(); - if matches!(source.chars().next(), Some('[' | '{')) { - return Ok(Source::String(source.to_owned())) - } - let root = env::var("CARGO_MANIFEST_DIR")?; - Source::with_root(root, source) - } - - /// Parses an artifact source from a string and a specified root directory - /// for resolving relative paths. See `Source::with_root` for more details - /// on supported source strings. - fn with_root(root: P, source: S) -> Result - where - P: AsRef, - S: AsRef, - { - let source = source.as_ref(); - let root = root.as_ref(); - cfg_if! { - if #[cfg(target_arch = "wasm32")] { - let root = if root.starts_with("/") { - format!("file:://{}", root.display()) - } else { - format!("{}", root.display()) - }; - let base = Url::parse(&root) - .map_err(|_| eyre!("root path '{}' is not absolute", root))?; - } else { - let base = Url::from_directory_path(root) - .map_err(|_| eyre!("root path '{}' is not absolute", root.display()))?; - } - } - let url = base.join(source)?; - - match url.scheme() { - "file" => Ok(Source::local(source)), - "http" | "https" => match url.host_str() { - Some("bscscan.com") => Source::etherscan( - url.path() - .rsplit('/') - .next() - .ok_or_else(|| eyre!("HTTP URL does not have a path"))?, - ), - Some("etherscan.io") => Source::etherscan( - url.path() - .rsplit('/') - .next() - .ok_or_else(|| eyre!("HTTP URL does not have a path"))?, - ), - Some("polygonscan.com") => Source::polygonscan( - url.path() - .rsplit('/') - .next() - .ok_or_else(|| eyre!("HTTP URL does not have a path"))?, - ), - Some("snowtrace.io") => Source::snowtrace( - url.path() - .rsplit('/') - .next() - .ok_or_else(|| eyre!("HTTP URL does not have a path"))?, - ), - _ => Ok(Source::Http(url)), - }, - "bscscan" => Source::bscscan(url.path()), - "etherscan" => Source::etherscan(url.path()), - "polygonscan" => Source::polygonscan(url.path()), - "snowtrace" => Source::snowtrace(url.path()), - "npm" => Ok(Source::npm(url.path())), - _ => Err(eyre!("unsupported URL '{}'", url)), - } - } - - /// Creates a local filesystem source from a path string. - pub fn local(path: impl Into) -> Self { - Source::Local(path.into()) - } - - /// Creates an HTTP source from a URL. - pub fn http(url: S) -> Result - where - S: AsRef, - { - Ok(Source::Http(Url::parse(url.as_ref())?)) - } - - /// Creates an Bscscan source from an address string. - pub fn bscscan(address: S) -> Result - where - S: AsRef, - { - let address = - util::parse_address(address).context("failed to parse address for Bscscan source")?; - Ok(Source::Bscscan(address)) - } - - /// Creates an Etherscan source from an address string. - pub fn etherscan(address: S) -> Result - where - S: AsRef, - { - let address = - util::parse_address(address).context("failed to parse address for Etherscan source")?; - Ok(Source::Etherscan(address)) - } - - /// Creates an Polygonscan source from an address string. - pub fn polygonscan(address: S) -> Result - where - S: AsRef, - { - let address = util::parse_address(address) - .context("failed to parse address for Polygonscan source")?; - Ok(Source::Polygonscan(address)) - } - - /// Creates an Snowtrace source from an address string. - pub fn snowtrace(address: S) -> Result - where - S: AsRef, - { - let address = - util::parse_address(address).context("failed to parse address for Snowtrace source")?; - Ok(Source::Snowtrace(address)) - } - - /// Creates an Etherscan source from an address string. - pub fn npm(package_path: S) -> Self - where - S: Into, - { - Source::Npm(package_path.into()) - } - - /// Retrieves the source JSON of the artifact this will either read the JSON - /// from the file system or retrieve a contract ABI from the network - /// depending on the source type. - pub fn get(&self) -> Result { - cfg_if! { - if #[cfg(target_arch = "wasm32")] { - match self { - Source::Local(path) => get_local_contract(path), - Source::Http(_) => panic!("Http abi location are not supported for wasm"), - Source::Bscscan(_) => panic!("Bscscan abi location are not supported for wasm"), - Source::Etherscan(_) => panic!("Etherscan abi location are not supported for wasm"), - Source::Polygonscan(_) => panic!("Polygonscan abi location are not supported for wasm"), - Source::Snowtrace(_) => panic!("Snowtrace abi location are not supported for wasm"), - Source::Npm(_) => panic!("npm abi location are not supported for wasm"), - Source::String(abi) => Ok(abi.clone()), - } - } else { - match self { - Source::Local(path) => get_local_contract(path), - Source::Http(url) => get_http_contract(url), - Source::Bscscan(address) => get_etherscan_contract(*address, "bscscan.com"), - Source::Etherscan(address) => get_etherscan_contract(*address, "etherscan.io"), - Source::Polygonscan(address) => get_etherscan_contract(*address, "polygonscan.com"), - Source::Snowtrace(address) => get_etherscan_contract(*address, "snowtrace.io"), - Source::Npm(package) => get_npm_contract(package), - Source::String(abi) => Ok(abi.clone()), - } - } - } - } -} - -impl FromStr for Source { - type Err = Error; - - fn from_str(s: &str) -> Result { - Source::parse(s) - } -} - -/// Reads an artifact JSON file from the local filesystem. -/// -/// The given path can be relative or absolute and can contain env vars like -/// `"$CARGO_MANIFEST_DIR/contracts/a.json"` -/// If the path is relative after all env vars have been resolved then we assume the root is either -/// `CARGO_MANIFEST_DIR` or the current working directory. -fn get_local_contract(path: impl AsRef) -> Result { - let path = resolve_path(path.as_ref())?; - let path = if path.is_relative() { - let manifest_path = env::var("CARGO_MANIFEST_DIR")?; - let root = Path::new(&manifest_path); - let mut contract_path = root.join(&path); - if !contract_path.exists() { - contract_path = dunce::canonicalize(&path)?; - } - if !contract_path.exists() { - eyre::bail!("Unable to find local contract \"{}\"", path.display()) - } - contract_path - } else { - path - }; - - let json = fs::read_to_string(&path) - .context(format!("failed to read artifact JSON file with path {}", &path.display()))?; - Ok(json) -} - -/// Retrieves a Truffle artifact or ABI from an HTTP URL. -#[cfg(not(target_arch = "wasm32"))] -fn get_http_contract(url: &Url) -> Result { - let json = util::http_get(url.as_str()) - .with_context(|| format!("failed to retrieve JSON from {url}"))?; - Ok(json) -} - -/// Retrieves a contract ABI from the Etherscan HTTP API and wraps it in an -/// artifact JSON for compatibility with the code generation facilities. -#[cfg(not(target_arch = "wasm32"))] -fn get_etherscan_contract(address: Address, domain: &str) -> Result { - // NOTE: We do not retrieve the bytecode since deploying contracts with the - // same bytecode is unreliable as the libraries have already linked and - // probably don't reference anything when deploying on other networks. - let api_key = { - let key_res = match domain { - "bscscan.com" => env::var("BSCSCAN_API_KEY").ok(), - "etherscan.io" => env::var("ETHERSCAN_API_KEY").ok(), - "polygonscan.com" => env::var("POLYGONSCAN_API_KEY").ok(), - "snowtrace.io" => env::var("SNOWTRACE_API_KEY").ok(), - _ => None, - }; - key_res.map(|key| format!("&apikey={key}")).unwrap_or_default() - }; - - let abi_url = format!( - "http://api.{domain}/api?module=contract&action=getabi&address={address:?}&format=raw{api_key}", - ); - let abi = util::http_get(&abi_url).context(format!("failed to retrieve ABI from {domain}"))?; - - if abi.starts_with("Contract source code not verified") { - eyre::bail!("Contract source code not verified: {:?}", address); - } - if abi.starts_with('{') && abi.contains("Max rate limit reached") { - eyre::bail!( - "Max rate limit reached, please use etherscan API Key for higher rate limit: {:?}", - address - ); - } - - Ok(abi) -} - -/// Retrieves a Truffle artifact or ABI from an npm package through `unpkg.io`. -#[cfg(not(target_arch = "wasm32"))] -fn get_npm_contract(package: &str) -> Result { - let unpkg_url = format!("https://unpkg.io/{package}"); - let json = util::http_get(&unpkg_url) - .with_context(|| format!("failed to retrieve JSON from for npm package {package}"))?; - - Ok(json) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_source() { - let root = "/rooted"; - for (url, expected) in &[ - ("relative/Contract.json", Source::local("/rooted/relative/Contract.json")), - ("/absolute/Contract.json", Source::local("/absolute/Contract.json")), - ( - "https://my.domain.eth/path/to/Contract.json", - Source::http("https://my.domain.eth/path/to/Contract.json").unwrap(), - ), - ( - "bscscan:0x0001020304050607080910111213141516171819", - Source::bscscan("0x0001020304050607080910111213141516171819").unwrap(), - ), - ( - "etherscan:0x0001020304050607080910111213141516171819", - Source::etherscan("0x0001020304050607080910111213141516171819").unwrap(), - ), - ( - "polygonscan:0x0001020304050607080910111213141516171819", - Source::polygonscan("0x0001020304050607080910111213141516171819").unwrap(), - ), - ( - "snowtrace:0x0001020304050607080910111213141516171819", - Source::snowtrace("0x0001020304050607080910111213141516171819").unwrap(), - ), - ( - "https://bscscan.io/address/0x0001020304050607080910111213141516171819", - Source::bscscan("0x0001020304050607080910111213141516171819").unwrap(), - ), - ( - "https://etherscan.io/address/0x0001020304050607080910111213141516171819", - Source::etherscan("0x0001020304050607080910111213141516171819").unwrap(), - ), - ( - "https://polygonscan.com/address/0x0001020304050607080910111213141516171819", - Source::polygonscan("0x0001020304050607080910111213141516171819").unwrap(), - ), - ( - "https://snowtrace.io/address/0x0001020304050607080910111213141516171819", - Source::snowtrace("0x0001020304050607080910111213141516171819").unwrap(), - ), - ( - "npm:@openzeppelin/contracts@2.5.0/build/contracts/IERC20.json", - Source::npm("@openzeppelin/contracts@2.5.0/build/contracts/IERC20.json"), - ), - ] { - let source = Source::with_root(root, url).unwrap(); - assert_eq!(source, *expected); - } - - let src = r#"[{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"name","type":"string"}],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"symbol","type":"string"}],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"decimals","type":"uint8"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"spender","type":"address"},{"name":"value","type":"uint256"}],"name":"approve","outputs":[{"name":"success","type":"bool"}],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"totalSupply","type":"uint256"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"from","type":"address"},{"name":"to","type":"address"},{"name":"value","type":"uint256"}],"name":"transferFrom","outputs":[{"name":"success","type":"bool"}],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"who","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"to","type":"address"},{"name":"value","type":"uint256"}],"name":"transfer","outputs":[{"name":"success","type":"bool"}],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"owner","type":"address"},{"name":"spender","type":"address"}],"name":"allowance","outputs":[{"name":"remaining","type":"uint256"}],"payable":false,"type":"function"},{"anonymous":false,"inputs":[{"indexed":true,"name":"owner","type":"address"},{"indexed":true,"name":"spender","type":"address"},{"indexed":false,"name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"from","type":"address"},{"indexed":true,"name":"to","type":"address"},{"indexed":false,"name":"value","type":"uint256"}],"name":"Transfer","type":"event"}]"#; - let parsed = Source::parse(src).unwrap(); - assert_eq!(parsed, Source::String(src.to_owned())); - - let hardhat_src = format!( - r#"{{"_format": "hh-sol-artifact-1", "contractName": "Verifier", "sourceName": "contracts/verifier.sol", "abi": {src}, "bytecode": "0x", "deployedBytecode": "0x", "linkReferences": {{}}, "deployedLinkReferences": {{}}}}"#, - ); - let hardhat_parsed = Source::parse(&hardhat_src).unwrap(); - assert_eq!(hardhat_parsed, Source::String(hardhat_src)); - } - - #[test] - #[ignore] - fn get_etherscan_contract() { - let source = Source::etherscan("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - let _dai = source.get().unwrap(); - } -} diff --git a/ethers-contract/ethers-contract-abigen/src/source/mod.rs b/ethers-contract/ethers-contract-abigen/src/source/mod.rs new file mode 100644 index 00000000..778df22c --- /dev/null +++ b/ethers-contract/ethers-contract-abigen/src/source/mod.rs @@ -0,0 +1,168 @@ +//! Parse ABI artifacts from different sources. + +// TODO: Support `online` for WASM + +#[cfg(all(feature = "online", not(target_arch = "wasm32")))] +mod online; +#[cfg(all(feature = "online", not(target_arch = "wasm32")))] +pub use online::Explorer; + +use crate::util; +use eyre::{Error, Result}; +use std::{env, fs, path::PathBuf, str::FromStr}; + +/// A source of an Ethereum smart contract's ABI. +/// +/// See [`parse`][#method.parse] for more information. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Source { + /// A raw ABI string. + String(String), + + /// An ABI located on the local file system. + Local(PathBuf), + + /// An address of a smart contract address verified at a supported blockchain explorer. + #[cfg(feature = "online")] + Explorer(Explorer, ethers_core::types::Address), + + /// The package identifier of an npm package with a path to a Truffle artifact or ABI to be + /// retrieved from `unpkg.io`. + #[cfg(feature = "online")] + Npm(String), + + /// An ABI to be retrieved over HTTP(S). + #[cfg(feature = "online")] + Http(url::Url), +} + +impl FromStr for Source { + type Err = Error; + + fn from_str(s: &str) -> Result { + Source::parse(s) + } +} + +impl Source { + /// Parses an ABI from a source. + /// + /// This method accepts the following: + /// + /// - `{ ... }` or `[ ... ]`: A raw or human-readable ABI object or array of objects. + /// + /// - `relative/path/to/Contract.json`: a relative path to an ABI JSON file. This relative path + /// is rooted in the current working directory. + /// + /// - `/absolute/path/to/Contract.json` or `file:///absolute/path/to/Contract.json`: an absolute + /// path or file URL to an ABI JSON file. + /// + /// If the `online` feature is enabled: + /// + /// - `npm:@org/package@1.0.0/path/to/contract.json`: A npmjs package with an optional version + /// and path (defaulting to the latest version and `index.js`), retrieved through `unpkg.io`. + /// + /// - `http://...`: an HTTP URL to a contract ABI.
Note: either the `rustls` or `openssl` + /// feature must be enabled to support *HTTPS* URLs. + /// + /// - `:
`, `:
` or `/.../
`: an address or URL of a + /// verified contract on a blockchain explorer.
Supported explorers and their respective + /// chain: + /// - `etherscan` -> `mainnet` + /// - `bscscan` -> `bsc` + /// - `polygonscan` -> `polygon` + /// - `snowtrace` -> `avalanche` + pub fn parse(source: impl AsRef) -> Result { + let source = source.as_ref().trim(); + match source.chars().next() { + Some('[' | '{') => Ok(Self::String(source.to_string())), + + #[cfg(not(feature = "online"))] + _ => Ok(Self::local(source)?), + + #[cfg(feature = "online")] + Some('/') => Self::local(source), + #[cfg(feature = "online")] + _ => Self::parse_online(source), + } + } + + /// Creates a local filesystem source from a path string. + pub fn local(path: impl AsRef) -> Result { + // resolve env vars + let path = path.as_ref().trim_start_matches("file://"); + let mut resolved = util::resolve_path(path)?; + + if resolved.is_relative() { + // set root at manifest dir, if the path exists + if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") { + let new = PathBuf::from(manifest_dir).join(&resolved); + if new.exists() { + resolved = new; + } + } + } + + // canonicalize + if let Ok(canonicalized) = dunce::canonicalize(&resolved) { + resolved = canonicalized; + } else { + return Err(eyre::eyre!("File does not exist: {}", resolved.display())) + } + + Ok(Source::Local(resolved)) + } + + /// Retrieves the source JSON of the artifact this will either read the JSON from the file + /// system or retrieve a contract ABI from the network depending on the source type. + pub fn get(&self) -> Result { + match self { + Self::Local(path) => Ok(fs::read_to_string(path)?), + Self::String(abi) => Ok(abi.clone()), + + #[cfg(feature = "online")] + _ => { + cfg_if::cfg_if! { + if #[cfg(target_arch = "wasm32")] { + Err(eyre::eyre!("Online ABI locations are currently unsupported for WASM builds.")) + } else { + self.get_online() + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn parse_source() { + let rel = "../tests/solidity-contracts/console.json"; + let abs = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/solidity-contracts/console.json"); + let abs_url = concat!( + "file://", + env!("CARGO_MANIFEST_DIR"), + "/../tests/solidity-contracts/console.json" + ); + let exp = Source::Local(Path::new(rel).canonicalize().unwrap()); + assert_eq!(Source::parse(rel).unwrap(), exp); + assert_eq!(Source::parse(abs).unwrap(), exp); + assert_eq!(Source::parse(abs_url).unwrap(), exp); + + // ABI + let source = r#"[{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"name","type":"string"}],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"symbol","type":"string"}],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"name":"decimals","type":"uint8"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"spender","type":"address"},{"name":"value","type":"uint256"}],"name":"approve","outputs":[{"name":"success","type":"bool"}],"payable":false,"type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"totalSupply","type":"uint256"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"from","type":"address"},{"name":"to","type":"address"},{"name":"value","type":"uint256"}],"name":"transferFrom","outputs":[{"name":"success","type":"bool"}],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"who","type":"address"}],"name":"balanceOf","outputs":[{"name":"balance","type":"uint256"}],"payable":false,"type":"function"},{"constant":false,"inputs":[{"name":"to","type":"address"},{"name":"value","type":"uint256"}],"name":"transfer","outputs":[{"name":"success","type":"bool"}],"payable":false,"type":"function"},{"constant":true,"inputs":[{"name":"owner","type":"address"},{"name":"spender","type":"address"}],"name":"allowance","outputs":[{"name":"remaining","type":"uint256"}],"payable":false,"type":"function"},{"anonymous":false,"inputs":[{"indexed":true,"name":"owner","type":"address"},{"indexed":true,"name":"spender","type":"address"},{"indexed":false,"name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"from","type":"address"},{"indexed":true,"name":"to","type":"address"},{"indexed":false,"name":"value","type":"uint256"}],"name":"Transfer","type":"event"}]"#; + let parsed = Source::parse(source).unwrap(); + assert_eq!(parsed, Source::String(source.to_owned())); + + // Hardhat-like artifact + let source = format!( + r#"{{"_format": "hh-sol-artifact-1", "contractName": "Verifier", "sourceName": "contracts/verifier.sol", "abi": {source}, "bytecode": "0x", "deployedBytecode": "0x", "linkReferences": {{}}, "deployedLinkReferences": {{}}}}"#, + ); + let parsed = Source::parse(&source).unwrap(); + assert_eq!(parsed, Source::String(source)); + } +} diff --git a/ethers-contract/ethers-contract-abigen/src/source/online.rs b/ethers-contract/ethers-contract-abigen/src/source/online.rs new file mode 100644 index 00000000..b8c200ca --- /dev/null +++ b/ethers-contract/ethers-contract-abigen/src/source/online.rs @@ -0,0 +1,209 @@ +use super::Source; +use crate::util; +use ethers_core::types::{Address, Chain}; +use ethers_etherscan::Client; +use eyre::{Context, Result}; +use std::{fmt, str::FromStr}; +use url::Url; + +/// An [etherscan](https://etherscan.io)-like blockchain explorer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Explorer { + #[default] + Etherscan, + Bscscan, + Polygonscan, + Snowtrace, +} + +impl FromStr for Explorer { + type Err = eyre::Report; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "etherscan" | "etherscan.io" => Ok(Self::Etherscan), + "bscscan" | "bscscan.com" => Ok(Self::Bscscan), + "polygonscan" | "polygonscan.com" => Ok(Self::Polygonscan), + "snowtrace" | "snowtrace.io" => Ok(Self::Snowtrace), + _ => Err(eyre::eyre!("Invalid or unsupported blockchain explorer: {s}")), + } + } +} + +impl fmt::Display for Explorer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +impl Explorer { + /// Returns the chain's Explorer, if it is known. + pub fn from_chain(chain: Chain) -> Result { + match chain { + Chain::Mainnet => Ok(Self::Etherscan), + Chain::BinanceSmartChain => Ok(Self::Bscscan), + Chain::Polygon => Ok(Self::Polygonscan), + Chain::Avalanche => Ok(Self::Snowtrace), + _ => Err(eyre::eyre!("Provided chain has no known blockchain explorer")), + } + } + + /// Returns the Explorer's chain. If it has multiple, the main one is returned. + pub const fn chain(&self) -> Chain { + match self { + Self::Etherscan => Chain::Mainnet, + Self::Bscscan => Chain::BinanceSmartChain, + Self::Polygonscan => Chain::Polygon, + Self::Snowtrace => Chain::Avalanche, + } + } + + /// Creates an `ethers-etherscan` client using this Explorer's settings. + pub fn client(self, api_key: Option) -> Result { + let chain = self.chain(); + let client = match api_key { + Some(api_key) => Client::new(chain, api_key), + None => Client::new_from_env(chain), + }?; + Ok(client) + } + + /// Retrieves a contract ABI from the Etherscan HTTP API and wraps it in an artifact JSON for + /// compatibility with the code generation facilities. + pub fn get(self, address: Address) -> Result { + // TODO: Improve this + let client = self.client(None)?; + let future = client.contract_abi(address); + let abi = match tokio::runtime::Handle::try_current() { + Ok(handle) => handle.block_on(future), + _ => tokio::runtime::Runtime::new().expect("Could not start runtime").block_on(future), + }?; + Ok(serde_json::to_string(&abi)?) + } +} + +impl Source { + #[inline] + pub(super) fn parse_online(source: &str) -> Result { + if let Ok(url) = Url::parse(source) { + match url.scheme() { + // file:// + "file" => Self::local(source), + + // npm: + "npm" => Ok(Self::npm(url.path())), + + // try first: /.../
+ // then: any http url + "http" | "https" => Ok(url + .host_str() + .and_then(|host| Self::from_explorer(host, &url).ok()) + .unwrap_or(Self::Http(url))), + + // custom scheme: :
+ // fallback: local fs path + scheme => Self::from_explorer(scheme, &url) + .or_else(|_| Self::local(source)) + .wrap_err("Invalid path or URL"), + } + } else { + // not a valid URL so fallback to path + Self::local(source) + } + } + + /// Parse `s` as an explorer ("etherscan"), explorer domain ("etherscan.io") or a chain that has + /// an explorer ("mainnet"). + /// + /// The URL can be either :
or /.../
+ fn from_explorer(s: &str, url: &Url) -> Result { + let explorer: Explorer = s.parse().or_else(|_| Explorer::from_chain(s.parse()?))?; + let address = last_segment_address(url).ok_or_else(|| eyre::eyre!("Invalid URL: {url}"))?; + Ok(Self::Explorer(explorer, address)) + } + + /// Creates an HTTP source from a URL. + pub fn http(url: impl AsRef) -> Result { + Ok(Self::Http(Url::parse(url.as_ref())?)) + } + + /// Creates an Etherscan source from an address string. + pub fn explorer(chain: Chain, address: Address) -> Result { + let explorer = Explorer::from_chain(chain)?; + Ok(Self::Explorer(explorer, address)) + } + + /// Creates an Etherscan source from an address string. + pub fn npm(package_path: impl Into) -> Self { + Self::Npm(package_path.into()) + } + + #[inline] + pub(super) fn get_online(&self) -> Result { + match self { + Self::Http(url) => { + util::http_get(url.clone()).wrap_err("Failed to retrieve ABI from URL") + } + Self::Explorer(explorer, address) => explorer.get(*address), + Self::Npm(package) => { + // TODO: const? + let unpkg = Url::parse("https://unpkg.io/").unwrap(); + let url = unpkg.join(package).wrap_err("Invalid NPM package")?; + util::http_get(url).wrap_err("Failed to retrieve ABI from NPM package") + } + _ => unreachable!(), + } + } +} + +fn last_segment_address(url: &Url) -> Option
{ + url.path().rsplit('/').next()?.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_online_source() { + assert_eq!( + Source::parse("https://my.domain.eth/path/to/Contract.json").unwrap(), + Source::http("https://my.domain.eth/path/to/Contract.json").unwrap() + ); + + assert_eq!( + Source::parse("npm:@openzeppelin/contracts@2.5.0/build/contracts/IERC20.json").unwrap(), + Source::npm("@openzeppelin/contracts@2.5.0/build/contracts/IERC20.json") + ); + + let explorers = &[ + ("mainnet:", "etherscan:", "https://etherscan.io/address/", Chain::Mainnet), + ("bsc:", "bscscan:", "https://bscscan.com/address/", Chain::BinanceSmartChain), + ("polygon:", "polygonscan:", "https://polygonscan.com/address/", Chain::Polygon), + ("avalanche:", "snowtrace:", "https://snowtrace.io/address/", Chain::Avalanche), + ]; + + let address: Address = "0x0102030405060708091011121314151617181920".parse().unwrap(); + for &(chain_s, scan_s, url_s, chain) in explorers { + let expected = Source::explorer(chain, address).unwrap(); + + let tests2 = [chain_s, scan_s, url_s].map(|s| s.to_string() + &format!("{address:?}")); + let tests2 = tests2.map(Source::parse).into_iter().chain(Some(Ok(expected.clone()))); + let tests2 = tests2.collect::>>().unwrap(); + + for slice in tests2.windows(2) { + let (a, b) = (&slice[0], &slice[1]); + if a != b { + panic!("Expected: {expected:?}; Got: {a:?} | {b:?}"); + } + } + } + } + + #[test] + fn get_mainnet_contract() { + let source = Source::parse("mainnet:0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + let abi = source.get().unwrap(); + assert!(!abi.is_empty()); + } +} diff --git a/ethers-contract/ethers-contract-abigen/src/util.rs b/ethers-contract/ethers-contract-abigen/src/util.rs index 74105744..92c4c4dc 100644 --- a/ethers-contract/ethers-contract-abigen/src/util.rs +++ b/ethers-contract/ethers-contract-abigen/src/util.rs @@ -1,7 +1,4 @@ -use ethers_core::{ - abi::{Param, ParamType}, - types::Address, -}; +use ethers_core::abi::{Param, ParamType}; use eyre::Result; use inflector::Inflector; use proc_macro2::{Ident, Literal, Span, TokenStream}; @@ -96,26 +93,10 @@ pub fn expand_derives(derives: &[Path]) -> TokenStream { quote! {#(#derives),*} } -/// Parses the given address string -pub fn parse_address(address_str: S) -> Result
-where - S: AsRef, -{ - let address_str = address_str.as_ref(); - eyre::ensure!(address_str.starts_with("0x"), "address must start with '0x'"); - Ok(address_str[2..].parse()?) -} - -/// Perform an HTTP GET request and return the contents of the response. -#[cfg(not(target_arch = "wasm32"))] -pub fn http_get(_url: &str) -> Result { - cfg_if::cfg_if! { - if #[cfg(feature = "reqwest")]{ - Ok(reqwest::blocking::get(_url)?.text()?) - } else { - eyre::bail!("HTTP is unsupported") - } - } +/// Perform a blocking HTTP GET request and return the contents of the response as a String. +#[cfg(all(feature = "online", not(target_arch = "wasm32")))] +pub fn http_get(url: impl reqwest::IntoUrl) -> Result { + Ok(reqwest::blocking::get(url)?.text()?) } /// Replaces any occurrences of env vars in the `raw` str with their value @@ -245,23 +226,6 @@ mod tests { assert_quote!(expand_input_name(0, "CamelCase1"), { camel_case_1 }); } - #[test] - fn parse_address_missing_prefix() { - let _ = parse_address("0000000000000000000000000000000000000000").unwrap_err(); - } - - #[test] - fn parse_address_address_too_short() { - let _ = parse_address("0x00000000000000").unwrap_err(); - } - - #[test] - fn parse_address_ok() { - let expected = - Address::from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); - assert_eq!(parse_address("0x000102030405060708090a0b0c0d0e0f10111213").unwrap(), expected); - } - #[test] fn test_safe_module_name() { assert_eq!(safe_module_name("Valid"), "valid");