Skip to content

Commit

Permalink
feat(cast interface): allow retrieving abi from contract name (foun…
Browse files Browse the repository at this point in the history
…dry-rs#8585)

* feat(`cast interface`): allow retrieving abi from contract name

* fix: cast tests

* test: add test that fetches weth interface from etherscan

* Revert "fix: cast tests"

This reverts commit c0ec3e9.

* fix: cast tests on macos

---------

Co-authored-by: Matthias Seitz <[email protected]>
  • Loading branch information
leovct and mattsse authored Aug 6, 2024
1 parent e9c8bf5 commit fbdd40d
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 70 deletions.
156 changes: 94 additions & 62 deletions crates/cast/bin/cmd/interface.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
use alloy_chains::Chain;
use alloy_json_abi::ContractObject;
use alloy_json_abi::{ContractObject, JsonAbi};
use alloy_primitives::Address;
use clap::Parser;
use eyre::{Context, Result};
use foundry_block_explorers::Client;
use foundry_cli::opts::EtherscanOpts;
use foundry_common::fs;
use foundry_config::Config;
use foundry_common::{compile::ProjectCompiler, fs};
use foundry_compilers::{info::ContractInfo, utils::canonicalize};
use foundry_config::{find_project_root_path, load_config_with_root, Config};
use itertools::Itertools;
use std::path::{Path, PathBuf};
use serde_json::Value;
use std::{
path::{Path, PathBuf},
str::FromStr,
};

/// CLI arguments for `cast interface`.
#[derive(Clone, Debug, Parser)]
pub struct InterfaceArgs {
/// The contract address, or the path to an ABI file.
///
/// If an address is specified, then the ABI is fetched from Etherscan.
path_or_address: String,
/// The target contract, which can be one of:
/// - A file path to an ABI JSON file.
/// - A contract identifier in the form `<path>:<contractname>` or just `<contractname>`.
/// - An Ethereum address, for which the ABI will be fetched from Etherscan.
contract: String,

/// The name to use for the generated interface.
///
/// Only relevant when retrieving the ABI from a file.
#[arg(long, short)]
name: Option<String>,

Expand Down Expand Up @@ -47,61 +54,32 @@ pub struct InterfaceArgs {

impl InterfaceArgs {
pub async fn run(self) -> Result<()> {
let Self { path_or_address, name, pragma, output: output_location, etherscan, json } = self;
let source = if Path::new(&path_or_address).exists() {
AbiPath::Local { path: path_or_address, name }
let Self { contract, name, pragma, output: output_location, etherscan, json } = self;

// Determine if the target contract is an ABI file, a local contract or an Ethereum address.
let abis = if Path::new(&contract).is_file() &&
fs::read_to_string(&contract)
.ok()
.and_then(|content| serde_json::from_str::<Value>(&content).ok())
.is_some()
{
load_abi_from_file(&contract, name)?
} else {
let config = Config::from(&etherscan);
let chain = config.chain.unwrap_or_default();
let api_key = config.get_etherscan_api_key(Some(chain)).unwrap_or_default();
AbiPath::Etherscan {
chain,
api_key,
address: path_or_address.parse().wrap_err("invalid path or address")?,
match Address::from_str(&contract) {
Ok(address) => fetch_abi_from_etherscan(address, &etherscan).await?,
Err(_) => load_abi_from_artifact(&contract)?,
}
};

let items = match source {
AbiPath::Local { path, name } => {
let file = std::fs::read_to_string(&path).wrap_err("unable to read abi file")?;
let obj: ContractObject = serde_json::from_str(&file)?;
let abi =
obj.abi.ok_or_else(|| eyre::eyre!("could not find ABI in file {path}"))?;
let name = name.unwrap_or_else(|| "Interface".to_owned());
vec![(abi, name)]
}
AbiPath::Etherscan { address, chain, api_key } => {
let client = Client::new(chain, api_key)?;
let source = client.contract_source_code(address).await?;
source
.items
.into_iter()
.map(|item| Ok((item.abi()?, item.contract_name)))
.collect::<Result<Vec<_>>>()?
}
};
// Retrieve interfaces from the array of ABIs.
let interfaces = get_interfaces(abis)?;

let interfaces = items
.into_iter()
.map(|(contract_abi, name)| {
let source = match foundry_cli::utils::abi_to_solidity(&contract_abi, &name) {
Ok(generated_source) => generated_source,
Err(e) => {
warn!("Failed to format interface for {name}: {e}");
contract_abi.to_sol(&name, None)
}
};
Ok(InterfaceSource {
json_abi: serde_json::to_string_pretty(&contract_abi)?,
source,
})
})
.collect::<Result<Vec<InterfaceSource>>>()?;

// put it all together
// Print result or write to file.
let res = if json {
// Format as JSON.
interfaces.iter().map(|iface| &iface.json_abi).format("\n").to_string()
} else {
// Format as Solidity.
format!(
"// SPDX-License-Identifier: UNLICENSED\n\
pragma solidity {pragma};\n\n\
Expand All @@ -110,7 +88,6 @@ impl InterfaceArgs {
)
};

// print or write to file
if let Some(loc) = output_location {
if let Some(parent) = loc.parent() {
fs::create_dir_all(parent)?;
Expand All @@ -120,6 +97,7 @@ impl InterfaceArgs {
} else {
print!("{res}");
}

Ok(())
}
}
Expand All @@ -129,9 +107,63 @@ struct InterfaceSource {
source: String,
}

// Local is a path to the directory containing the ABI files
// In case of etherscan, ABI is fetched from the address on the chain
enum AbiPath {
Local { path: String, name: Option<String> },
Etherscan { address: Address, chain: Chain, api_key: String },
/// Load the ABI from a file.
fn load_abi_from_file(path: &str, name: Option<String>) -> Result<Vec<(JsonAbi, String)>> {
let file = std::fs::read_to_string(path).wrap_err("unable to read abi file")?;
let obj: ContractObject = serde_json::from_str(&file)?;
let abi = obj.abi.ok_or_else(|| eyre::eyre!("could not find ABI in file {path}"))?;
let name = name.unwrap_or_else(|| "Interface".to_owned());
Ok(vec![(abi, name)])
}

/// Load the ABI from the artifact of a locally compiled contract.
fn load_abi_from_artifact(path_or_contract: &str) -> Result<Vec<(JsonAbi, String)>> {
let root = find_project_root_path(None)?;
let config = load_config_with_root(Some(root));
let project = config.project()?;
let compiler = ProjectCompiler::new().quiet(true);

let contract = ContractInfo::new(path_or_contract);
let target_path = if let Some(path) = &contract.path {
canonicalize(project.root().join(path))?
} else {
project.find_contract_path(&contract.name)?
};
let mut output = compiler.files([target_path.clone()]).compile(&project)?;

let artifact = output.remove(&target_path, &contract.name).ok_or_else(|| {
eyre::eyre!("Could not find artifact `{contract}` in the compiled artifacts")
})?;
let abi = artifact.abi.as_ref().ok_or_else(|| eyre::eyre!("Failed to fetch lossless ABI"))?;
Ok(vec![(abi.clone(), contract.name)])
}

/// Fetches the ABI of a contract from Etherscan.
async fn fetch_abi_from_etherscan(
address: Address,
etherscan: &EtherscanOpts,
) -> Result<Vec<(JsonAbi, String)>> {
let config = Config::from(etherscan);
let chain = config.chain.unwrap_or_default();
let api_key = config.get_etherscan_api_key(Some(chain)).unwrap_or_default();
let client = Client::new(chain, api_key)?;
let source = client.contract_source_code(address).await?;
source.items.into_iter().map(|item| Ok((item.abi()?, item.contract_name))).collect()
}

/// Converts a vector of tuples containing the ABI and contract name into a vector of
/// `InterfaceSource` objects.
fn get_interfaces(abis: Vec<(JsonAbi, String)>) -> Result<Vec<InterfaceSource>> {
abis.into_iter()
.map(|(contract_abi, name)| {
let source = match foundry_cli::utils::abi_to_solidity(&contract_abi, &name) {
Ok(generated_source) => generated_source,
Err(e) => {
warn!("Failed to format interface for {name}: {e}");
contract_abi.to_sol(&name, None)
}
};
Ok(InterfaceSource { json_abi: serde_json::to_string_pretty(&contract_abi)?, source })
})
.collect()
}
20 changes: 12 additions & 8 deletions crates/cast/bin/cmd/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,29 +391,32 @@ mod tests {
)
.err()
.unwrap()
.to_string();
.to_string()
.to_lowercase();

assert_eq!(err, "parser error:\n1234\n^\nInvalid string length");
assert_eq!(err, "parser error:\n1234\n^\ninvalid string length");
}

#[test]
fn test_build_filter_with_invalid_sig_or_topic() {
let err = build_filter(None, None, None, Some("asdasdasd".to_string()), vec![])
.err()
.unwrap()
.to_string();
.to_string()
.to_lowercase();

assert_eq!(err, "Odd number of digits");
assert_eq!(err, "odd number of digits");
}

#[test]
fn test_build_filter_with_invalid_sig_or_topic_hex() {
let err = build_filter(None, None, None, Some(ADDRESS.to_string()), vec![])
.err()
.unwrap()
.to_string();
.to_string()
.to_lowercase();

assert_eq!(err, "Invalid string length");
assert_eq!(err, "invalid string length");
}

#[test]
Expand All @@ -427,8 +430,9 @@ mod tests {
)
.err()
.unwrap()
.to_string();
.to_string()
.to_lowercase();

assert_eq!(err, "Invalid string length");
assert_eq!(err, "invalid string length");
}
}
34 changes: 34 additions & 0 deletions crates/cast/tests/cli/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,40 @@ interface Interface {
assert_eq!(output.trim(), s);
});

// tests that fetches WETH interface from etherscan
// <https://etherscan.io/token/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2>
casttest!(fetch_weth_interface_from_etherscan, |_prj, cmd| {
let weth_address = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
let api_key = "ZUB97R31KSYX7NYVW6224Q6EYY6U56H591";
cmd.args(["interface", "--etherscan-api-key", api_key, weth_address]);
let output = cmd.stdout_lossy();

let weth_interface = r#"// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.4;
interface WETH9 {
event Approval(address indexed src, address indexed guy, uint256 wad);
event Deposit(address indexed dst, uint256 wad);
event Transfer(address indexed src, address indexed dst, uint256 wad);
event Withdrawal(address indexed src, uint256 wad);
fallback() external payable;
function allowance(address, address) external view returns (uint256);
function approve(address guy, uint256 wad) external returns (bool);
function balanceOf(address) external view returns (uint256);
function decimals() external view returns (uint8);
function deposit() external payable;
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function totalSupply() external view returns (uint256);
function transfer(address dst, uint256 wad) external returns (bool);
function transferFrom(address src, address dst, uint256 wad) external returns (bool);
function withdraw(uint256 wad) external;
}"#;
assert_eq!(output.trim(), weth_interface);
});

const ENS_NAME: &str = "emo.eth";
const ENS_NAMEHASH: B256 =
b256!("0a21aaf2f6414aa664deb341d1114351fdb023cad07bf53b28e57c26db681910");
Expand Down

0 comments on commit fbdd40d

Please sign in to comment.