use anyhow::{Context, Result, bail};
use serde::Deserialize;
const KNOWN_NETWORKS: &[(&str, &str)] = &[
("mainnet", "https://api.etherscan.io/v2/api?chainid=1"),
("ethereum", "https://api.etherscan.io/v2/api?chainid=1"),
("eth", "https://api.etherscan.io/v2/api?chainid=1"),
(
"sepolia",
"https://api.etherscan.io/v2/api?chainid=11155111",
),
("holesky", "https://api.etherscan.io/v2/api?chainid=17000"),
("hoodi", "https://api.etherscan.io/v2/api?chainid=560048"),
("optimism", "https://api.etherscan.io/v2/api?chainid=10"),
("op", "https://api.etherscan.io/v2/api?chainid=10"),
(
"op-sepolia",
"https://api.etherscan.io/v2/api?chainid=11155420",
),
(
"optimism-sepolia",
"https://api.etherscan.io/v2/api?chainid=11155420",
),
("base", "https://api.etherscan.io/v2/api?chainid=8453"),
(
"base-sepolia",
"https://api.etherscan.io/v2/api?chainid=84532",
),
("blast", "https://api.etherscan.io/v2/api?chainid=81457"),
(
"blast-sepolia",
"https://api.etherscan.io/v2/api?chainid=168587773",
),
("frax", "https://api.etherscan.io/v2/api?chainid=252"),
("fraxtal", "https://api.etherscan.io/v2/api?chainid=252"),
(
"fraxtal-hoodi",
"https://api.etherscan.io/v2/api?chainid=2523",
),
("world", "https://api.etherscan.io/v2/api?chainid=480"),
("worldchain", "https://api.etherscan.io/v2/api?chainid=480"),
(
"world-sepolia",
"https://api.etherscan.io/v2/api?chainid=4801",
),
("arbitrum", "https://api.etherscan.io/v2/api?chainid=42161"),
("arb", "https://api.etherscan.io/v2/api?chainid=42161"),
(
"arbitrum-nova",
"https://api.etherscan.io/v2/api?chainid=42170",
),
(
"arbitrum-sepolia",
"https://api.etherscan.io/v2/api?chainid=421614",
),
(
"arb-sepolia",
"https://api.etherscan.io/v2/api?chainid=421614",
),
("polygon", "https://api.etherscan.io/v2/api?chainid=137"),
("matic", "https://api.etherscan.io/v2/api?chainid=137"),
(
"polygon-amoy",
"https://api.etherscan.io/v2/api?chainid=80002",
),
("amoy", "https://api.etherscan.io/v2/api?chainid=80002"),
("bsc", "https://api.etherscan.io/v2/api?chainid=56"),
("bnb", "https://api.etherscan.io/v2/api?chainid=56"),
("bsc-testnet", "https://api.etherscan.io/v2/api?chainid=97"),
("opbnb", "https://api.etherscan.io/v2/api?chainid=204"),
(
"opbnb-testnet",
"https://api.etherscan.io/v2/api?chainid=5611",
),
("bittorrent", "https://api.etherscan.io/v2/api?chainid=199"),
("btt", "https://api.etherscan.io/v2/api?chainid=199"),
(
"btt-testnet",
"https://api.etherscan.io/v2/api?chainid=1029",
),
("avalanche", "https://api.etherscan.io/v2/api?chainid=43114"),
("avax", "https://api.etherscan.io/v2/api?chainid=43114"),
(
"avalanche-fuji",
"https://api.etherscan.io/v2/api?chainid=43113",
),
("fuji", "https://api.etherscan.io/v2/api?chainid=43113"),
("linea", "https://api.etherscan.io/v2/api?chainid=59144"),
(
"linea-sepolia",
"https://api.etherscan.io/v2/api?chainid=59141",
),
("scroll", "https://api.etherscan.io/v2/api?chainid=534352"),
(
"scroll-sepolia",
"https://api.etherscan.io/v2/api?chainid=534351",
),
("gnosis", "https://api.etherscan.io/v2/api?chainid=100"),
("xdai", "https://api.etherscan.io/v2/api?chainid=100"),
("mantle", "https://api.etherscan.io/v2/api?chainid=5000"),
(
"mantle-sepolia",
"https://api.etherscan.io/v2/api?chainid=5003",
),
("celo", "https://api.etherscan.io/v2/api?chainid=42220"),
(
"celo-alfajores",
"https://api.etherscan.io/v2/api?chainid=44787",
),
("moonbeam", "https://api.etherscan.io/v2/api?chainid=1284"),
("glmr", "https://api.etherscan.io/v2/api?chainid=1284"),
("moonriver", "https://api.etherscan.io/v2/api?chainid=1285"),
("movr", "https://api.etherscan.io/v2/api?chainid=1285"),
("moonbase", "https://api.etherscan.io/v2/api?chainid=1287"),
("taiko", "https://api.etherscan.io/v2/api?chainid=167000"),
(
"taiko-hoodi",
"https://api.etherscan.io/v2/api?chainid=167013",
),
("sonic", "https://api.etherscan.io/v2/api?chainid=146"),
(
"sonic-testnet",
"https://api.etherscan.io/v2/api?chainid=14601",
),
("unichain", "https://api.etherscan.io/v2/api?chainid=130"),
(
"unichain-sepolia",
"https://api.etherscan.io/v2/api?chainid=1301",
),
("hyperevm", "https://api.etherscan.io/v2/api?chainid=999"),
("hype", "https://api.etherscan.io/v2/api?chainid=999"),
("abstract", "https://api.etherscan.io/v2/api?chainid=2741"),
(
"abstract-sepolia",
"https://api.etherscan.io/v2/api?chainid=11124",
),
("berachain", "https://api.etherscan.io/v2/api?chainid=80094"),
("bera", "https://api.etherscan.io/v2/api?chainid=80094"),
(
"berachain-bepolia",
"https://api.etherscan.io/v2/api?chainid=80069",
),
("swellchain", "https://api.etherscan.io/v2/api?chainid=1923"),
("swell", "https://api.etherscan.io/v2/api?chainid=1923"),
(
"swellchain-testnet",
"https://api.etherscan.io/v2/api?chainid=1924",
),
("monad", "https://api.etherscan.io/v2/api?chainid=143"),
(
"monad-testnet",
"https://api.etherscan.io/v2/api?chainid=10143",
),
("apechain", "https://api.etherscan.io/v2/api?chainid=33139"),
("ape", "https://api.etherscan.io/v2/api?chainid=33139"),
(
"apechain-curtis",
"https://api.etherscan.io/v2/api?chainid=33111",
),
("xdc", "https://api.etherscan.io/v2/api?chainid=50"),
("xdc-apothem", "https://api.etherscan.io/v2/api?chainid=51"),
("sei", "https://api.etherscan.io/v2/api?chainid=1329"),
(
"sei-testnet",
"https://api.etherscan.io/v2/api?chainid=1328",
),
("megaeth", "https://api.etherscan.io/v2/api?chainid=4326"),
(
"megaeth-testnet",
"https://api.etherscan.io/v2/api?chainid=6342",
),
("katana", "https://api.etherscan.io/v2/api?chainid=747474"),
(
"katana-bokuto",
"https://api.etherscan.io/v2/api?chainid=737373",
),
("polygon-zkevm", "https://api-zkevm.polygonscan.com/api"),
("zkevm", "https://api-zkevm.polygonscan.com/api"),
(
"polygon-zkevm-cardona",
"https://api-cardona-zkevm.polygonscan.com/api",
),
("zksync", "https://api-era.zksync.network/api"),
(
"zksync-sepolia",
"https://api-sepolia-era.zksync.network/api",
),
("fantom", "https://api.ftmscan.com/api"),
("ftm", "https://api.ftmscan.com/api"),
("fantom-testnet", "https://api-testnet.ftmscan.com/api"),
("cronos", "https://api.cronoscan.com/api"),
("cro", "https://api.cronoscan.com/api"),
("metis", "https://andromeda-explorer.metis.io/api"),
("manta", "https://pacific-explorer.manta.network/api"),
];
pub fn network_to_api_url(network: &str) -> Option<&'static str> {
KNOWN_NETWORKS
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case(network))
.map(|(_, url)| *url)
}
pub fn resolve_api_url<'a>(network: &str, explicit_url: Option<&'a str>) -> Result<&'a str>
where
{
if let Some(url) = explicit_url {
return Ok(url);
}
network_to_api_url(network).ok_or_else(|| {
anyhow::anyhow!(
"unknown network '{}'; pass --url with the full Etherscan-compatible API URL \
(e.g. https://api.sonicscan.org/api)",
network
)
})
}
#[derive(Deserialize)]
struct ExplorerResponse {
status: String,
result: String,
}
pub fn fetch_abi(api_url: &str, address: &str, api_key: Option<&str>) -> Result<serde_json::Value> {
let sep = if api_url.contains('?') { '&' } else { '?' };
let mut url = format!(
"{}{}module=contract&action=getabi&address={}",
api_url, sep, address
);
if let Some(key) = api_key {
url.push_str("&apikey=");
url.push_str(key);
}
let body: String = ureq::get(&url)
.call()
.context("HTTP request to block explorer failed")?
.into_string()
.context("failed to read block explorer response body")?;
let resp: ExplorerResponse =
serde_json::from_str(&body).context("failed to parse block explorer response")?;
if resp.status != "1" {
let msg = resp.result.as_str();
if msg.contains("not verified") || msg.contains("not found") || msg.contains("No Source") {
bail!(
"contract {} is not verified on this explorer ({})",
address,
api_url
);
}
if msg.contains("Invalid API Key") || msg.contains("API Key") {
bail!("invalid or missing API key — provide --api-key or set ETHERSCAN_API_KEY");
}
if msg.contains("rate limit") || msg.contains("rate_limit") || msg.contains("Max rate") {
bail!("rate limit reached — add an API key or wait before retrying");
}
bail!("block explorer returned error: {}", msg);
}
serde_json::from_str(&resp.result)
.context("block explorer returned invalid ABI JSON in `result` field")
}
pub fn load_abi_from_file(path: &std::path::Path) -> Result<serde_json::Value> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("cannot read ABI file '{}'", path.display()))?;
let value: serde_json::Value = serde_json::from_str(&contents)
.with_context(|| format!("'{}' is not valid JSON", path.display()))?;
if value.is_array() {
return Ok(value);
}
if let Some(abi) = value.get("abi")
&& abi.is_array()
{
return Ok(abi.clone());
}
bail!(
"'{}' is not a JSON ABI array or a Foundry/Hardhat artifact with an \"abi\" field",
path.display()
)
}
pub fn build_artifact_json(abi: serde_json::Value) -> Result<String> {
let artifact = serde_json::json!({ "abi": abi });
serde_json::to_string_pretty(&artifact).context("failed to serialise artifact JSON")
}
pub fn artifact_path(artifacts_dir: &std::path::Path, name: &str) -> std::path::PathBuf {
artifacts_dir
.join(format!("{}.sol", name))
.join(format!("{}.json", name))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::sync::mpsc;
fn serve_once(
body: &'static str,
) -> (String, mpsc::Receiver<String>, std::thread::JoinHandle<()>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/api", listener.local_addr().unwrap());
let (tx, rx) = mpsc::channel();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 4096];
let bytes = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..bytes]).to_string();
tx.send(request).unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
});
(url, rx, handle)
}
#[test]
fn mainnet_resolves() {
assert_eq!(
network_to_api_url("mainnet"),
Some("https://api.etherscan.io/v2/api?chainid=1")
);
assert_eq!(
network_to_api_url("ethereum"),
Some("https://api.etherscan.io/v2/api?chainid=1")
);
}
#[test]
fn lookup_is_case_insensitive() {
assert_eq!(network_to_api_url("Mainnet"), network_to_api_url("mainnet"));
assert_eq!(network_to_api_url("POLYGON"), network_to_api_url("polygon"));
}
#[test]
fn unknown_network_returns_none() {
assert!(network_to_api_url("solana").is_none());
assert!(network_to_api_url("").is_none());
}
#[test]
fn explicit_url_overrides_network() {
let url = "https://api.sonicscan.org/api";
assert_eq!(resolve_api_url("mainnet", Some(url)).unwrap(), url);
}
#[test]
fn network_lookup_used_when_no_explicit_url() {
let result = resolve_api_url("polygon", None).unwrap();
assert_eq!(result, "https://api.etherscan.io/v2/api?chainid=137");
}
#[test]
fn unknown_network_without_url_errors() {
let err = resolve_api_url("hyperevmtestnet", None).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("hyperevmtestnet"), "{msg}");
assert!(msg.contains("--url"), "{msg}");
}
#[test]
fn artifact_path_follows_foundry_layout() {
let base = std::path::Path::new("out");
let path = artifact_path(base, "MyToken");
assert_eq!(path, std::path::Path::new("out/MyToken.sol/MyToken.json"));
}
#[test]
fn build_artifact_json_wraps_abi() {
let abi = serde_json::json!([{"type": "fallback"}]);
let json = build_artifact_json(abi).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["abi"].is_array());
assert_eq!(parsed["abi"][0]["type"], "fallback");
}
#[test]
fn error_response_not_verified_detected() {
let msg = "Contract source code not verified";
assert!(
msg.contains("not verified"),
"pattern check for unverified contract"
);
}
#[test]
fn fetch_abi_builds_request_and_decodes_success_response() {
let body = r#"{"status":"1","result":"[{\"type\":\"function\",\"name\":\"balanceOf\"}]"}"#;
let (url, rx, handle) = serve_once(body);
let api_url = format!("{url}?chainid=1");
let abi = fetch_abi(
&api_url,
"0x0000000000000000000000000000000000000001",
Some("test-key"),
)
.unwrap();
assert_eq!(abi[0]["type"], "function");
assert_eq!(abi[0]["name"], "balanceOf");
let request = rx.recv().unwrap();
assert!(request.starts_with(
"GET /api?chainid=1&module=contract&action=getabi&address=0x0000000000000000000000000000000000000001&apikey=test-key "
));
handle.join().unwrap();
}
#[test]
fn fetch_abi_uses_question_mark_for_plain_api_urls() {
let body = r#"{"status":"1","result":"[]"}"#;
let (url, rx, handle) = serve_once(body);
let abi = fetch_abi(&url, "0x0000000000000000000000000000000000000002", None).unwrap();
assert!(abi.as_array().unwrap().is_empty());
let request = rx.recv().unwrap();
assert!(request.starts_with(
"GET /api?module=contract&action=getabi&address=0x0000000000000000000000000000000000000002 "
));
assert!(!request.contains("apikey="));
handle.join().unwrap();
}
#[test]
fn fetch_abi_maps_explorer_error_messages() {
let cases = [
(
r#"{"status":"0","result":"Contract source code not verified"}"#,
"not verified on this explorer",
),
(
r#"{"status":"0","result":"Invalid API Key"}"#,
"invalid or missing API key",
),
(
r#"{"status":"0","result":"Max rate limit reached"}"#,
"rate limit reached",
),
(
r#"{"status":"0","result":"temporary explorer failure"}"#,
"block explorer returned error: temporary explorer failure",
),
];
for (body, expected) in cases {
let (url, _rx, handle) = serve_once(body);
let err =
fetch_abi(&url, "0x0000000000000000000000000000000000000003", None).unwrap_err();
assert!(err.to_string().contains(expected), "{err}");
handle.join().unwrap();
}
}
#[test]
fn fetch_abi_rejects_invalid_result_json() {
let body = r#"{"status":"1","result":"not-json"}"#;
let (url, _rx, handle) = serve_once(body);
let err = fetch_abi(&url, "0x0000000000000000000000000000000000000004", None).unwrap_err();
assert!(
format!("{err:#}").contains("block explorer returned invalid ABI JSON"),
"{err:#}"
);
handle.join().unwrap();
}
#[test]
fn load_abi_from_file_accepts_raw_array() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("abi.json");
std::fs::write(&path, r#"[{"type":"fallback"}]"#).unwrap();
let abi = load_abi_from_file(&path).unwrap();
assert!(abi.is_array());
assert_eq!(abi[0]["type"], "fallback");
}
#[test]
fn load_abi_from_file_accepts_artifact_envelope() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("artifact.json");
std::fs::write(&path, r#"{"abi":[{"type":"receive"}],"bytecode":"0x"}"#).unwrap();
let abi = load_abi_from_file(&path).unwrap();
assert!(abi.is_array());
assert_eq!(abi[0]["type"], "receive");
}
#[test]
fn load_abi_from_file_rejects_unknown_shape() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, r#"{"name":"Foo"}"#).unwrap();
let err = load_abi_from_file(&path).unwrap_err();
assert!(format!("{err}").contains("not a JSON ABI array"), "{err}");
}
}