refactor(abigen): source (#2016)

* rm parse_address

* refactor: sources

* add comments, support <chain>:<address>

* fix doc

* chore: clippy

* fmt

* fix

Co-authored-by: Georgios Konstantopoulos <me@gakonst.com>
This commit is contained in:
DaniPopes 2023-01-13 19:59:44 +01:00 committed by GitHub
parent 0841e9b53e
commit c2d7b8321f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 395 additions and 449 deletions

2
Cargo.lock generated
View File

@ -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",

View File

@ -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"

View File

@ -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};

View File

@ -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<S>(source: S) -> Result<Self>
where
S: AsRef<str>,
{
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<P, S>(root: P, source: S) -> Result<Self>
where
P: AsRef<Path>,
S: AsRef<str>,
{
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<String>) -> Self {
Source::Local(path.into())
}
/// Creates an HTTP source from a URL.
pub fn http<S>(url: S) -> Result<Self>
where
S: AsRef<str>,
{
Ok(Source::Http(Url::parse(url.as_ref())?))
}
/// Creates an Bscscan source from an address string.
pub fn bscscan<S>(address: S) -> Result<Self>
where
S: AsRef<str>,
{
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<S>(address: S) -> Result<Self>
where
S: AsRef<str>,
{
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<S>(address: S) -> Result<Self>
where
S: AsRef<str>,
{
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<S>(address: S) -> Result<Self>
where
S: AsRef<str>,
{
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<S>(package_path: S) -> Self
where
S: Into<String>,
{
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<String> {
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<Self> {
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<str>) -> Result<String> {
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<String> {
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<String> {
// 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<String> {
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();
}
}

View File

@ -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<Self> {
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. <br> Note: either the `rustls` or `openssl`
/// feature must be enabled to support *HTTPS* URLs.
///
/// - `<name>:<address>`, `<chain>:<address>` or `<url>/.../<address>`: an address or URL of a
/// verified contract on a blockchain explorer. <br> Supported explorers and their respective
/// chain:
/// - `etherscan` -> `mainnet`
/// - `bscscan` -> `bsc`
/// - `polygonscan` -> `polygon`
/// - `snowtrace` -> `avalanche`
pub fn parse(source: impl AsRef<str>) -> Result<Self> {
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<str>) -> Result<Self> {
// 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<String> {
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));
}
}

View File

@ -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<Self> {
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<Self> {
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<String>) -> Result<Client> {
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<String> {
// 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<Self> {
if let Ok(url) = Url::parse(source) {
match url.scheme() {
// file://<path>
"file" => Self::local(source),
// npm:<npm package>
"npm" => Ok(Self::npm(url.path())),
// try first: <explorer url>/.../<address>
// 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: <explorer or chain>:<address>
// 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 <explorer>:<address> or <explorer_url>/.../<address>
fn from_explorer(s: &str, url: &Url) -> Result<Self> {
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<str>) -> Result<Self> {
Ok(Self::Http(Url::parse(url.as_ref())?))
}
/// Creates an Etherscan source from an address string.
pub fn explorer(chain: Chain, address: Address) -> Result<Self> {
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<String>) -> Self {
Self::Npm(package_path.into())
}
#[inline]
pub(super) fn get_online(&self) -> Result<String> {
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<Address> {
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::<Result<Vec<_>>>().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());
}
}

View File

@ -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<S>(address_str: S) -> Result<Address>
where
S: AsRef<str>,
{
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<String> {
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<String> {
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");