219 lines
7.7 KiB
Rust
219 lines
7.7 KiB
Rust
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 {
|
|
/// <https://etherscan.io>
|
|
#[default]
|
|
Etherscan,
|
|
/// <https://bscscan.com>
|
|
Bscscan,
|
|
/// <https://polygonscan.com>
|
|
Polygonscan,
|
|
/// <https://snowtrace.io>
|
|
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_opt_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() {
|
|
// Skip if ETHERSCAN_API_KEY is not set
|
|
if std::env::var("ETHERSCAN_API_KEY").is_err() {
|
|
return
|
|
}
|
|
|
|
let source = Source::parse("mainnet:0x6b175474e89094c44da98b954eedeac495271d0f").unwrap();
|
|
let abi = source.get().unwrap();
|
|
assert!(!abi.is_empty());
|
|
}
|
|
}
|