Skip to main content

cast/cmd/
interface.rs

1use alloy_json_abi::{ContractObject, InternalType, JsonAbi, Param, ToSolConfig};
2use alloy_primitives::{
3    Address,
4    map::{HashMap, HashSet},
5};
6use clap::Parser;
7use eyre::{Context, Result};
8use forge_fmt::FormatterConfig;
9use foundry_cli::{
10    json::print_json_object,
11    opts::EtherscanOpts,
12    utils::{LoadConfig, fetch_abi_from_etherscan},
13};
14use foundry_common::{
15    ContractsByArtifact,
16    compile::{PathOrContractInfo, ProjectCompiler, compile_abi_project},
17    find_target_path, fs, shell,
18};
19use foundry_config::load_config;
20use itertools::Itertools;
21use serde_json::Value;
22use std::{path::PathBuf, str::FromStr};
23
24/// CLI arguments for `cast interface`.
25#[derive(Clone, Debug, Parser)]
26pub struct InterfaceArgs {
27    /// The target contract, which can be one of:
28    /// - A file path to an ABI JSON file.
29    /// - A contract identifier in the form `<path>:<contractname>` or just `<contractname>`.
30    /// - An Ethereum address, for which the ABI will be fetched from Etherscan. If Etherscan
31    ///   reports the contract as a proxy, the ABI of its implementation is included as well.
32    contract: String,
33
34    /// The name to use for the generated interface.
35    ///
36    /// Only relevant when retrieving the ABI from a file.
37    #[arg(long, short)]
38    name: Option<String>,
39
40    /// Solidity pragma version.
41    #[arg(long, short, default_value = "^0.8.4", value_name = "VERSION")]
42    pragma: String,
43
44    /// The path to the output file.
45    ///
46    /// If not specified, the interface will be output to stdout.
47    #[arg(
48        short,
49        long,
50        value_hint = clap::ValueHint::FilePath,
51        value_name = "PATH",
52    )]
53    output: Option<PathBuf>,
54
55    /// If set, generate all types in a single interface, inlining any inherited or library types.
56    ///
57    /// This can fail if there are structs with the same name in different interfaces.
58    #[arg(long)]
59    flatten: bool,
60
61    #[command(flatten)]
62    etherscan: EtherscanOpts,
63}
64
65impl InterfaceArgs {
66    pub async fn run(self) -> Result<()> {
67        let Self { contract, name, pragma, output, flatten, etherscan } = self;
68
69        // The target is an ABI file, an Ethereum address, or a local contract.
70        let is_json_file = fs::read_to_string(&contract)
71            .is_ok_and(|content| serde_json::from_str::<Value>(&content).is_ok());
72        let abis = if is_json_file {
73            vec![(load_abi_from_file(&contract)?, name.unwrap_or_else(|| "Interface".to_owned()))]
74        } else if let Ok(address) = Address::from_str(&contract) {
75            fetch_abi_from_etherscan(address, &etherscan.load_config()?, true).await?
76        } else {
77            vec![load_abi_from_artifact(&contract)?]
78        };
79
80        let config = flatten.then(|| ToSolConfig::new().one_contract(true));
81        let mut json_abis = Vec::with_capacity(abis.len());
82        let mut sources = Vec::with_capacity(abis.len());
83        let multiple = abis.len() > 1;
84        let mut declarations = HashSet::default();
85        for (mut abi, mut name) in abis {
86            json_abis.push(serde_json::to_value(&abi)?);
87            abi.dedup();
88            if multiple {
89                let mut names = HashMap::<_, _>::default();
90                let unique = unique_declaration_name(&name, &mut declarations, Some(&abi));
91                names.insert(name, unique.clone());
92                name = unique;
93                visit_abi_types(&mut abi, &mut |ty| {
94                    if let InternalType::Struct { contract: Some(contract), .. }
95                    | InternalType::Enum { contract: Some(contract), .. }
96                    | InternalType::Other { contract: Some(contract), .. } = ty
97                    {
98                        *contract = names
99                            .entry(contract.clone())
100                            .or_insert_with(|| {
101                                if flatten {
102                                    contract.clone()
103                                } else {
104                                    unique_declaration_name(contract, &mut declarations, None)
105                                }
106                            })
107                            .clone();
108                    }
109                });
110            }
111            let source = abi.to_sol(&name, config.clone());
112            sources.push(
113                match forge_fmt::format(&source, FormatterConfig::default()).into_result() {
114                    Ok(formatted) => formatted,
115                    Err(e) => {
116                        sh_warn!("Failed to format interface for {name}: {e}")?;
117                        source
118                    }
119                },
120            );
121        }
122        let source = format!(
123            "// SPDX-License-Identifier: UNLICENSED\n\
124             pragma solidity {pragma};\n\n\
125             {}",
126            sources.iter().format("\n")
127        );
128
129        if let Some(loc) = output {
130            let res =
131                if shell::is_json() { serde_json::to_string_pretty(&json_abis)? } else { source };
132            if let Some(parent) = loc.parent() {
133                fs::create_dir_all(parent)?;
134            }
135            fs::write(&loc, res)?;
136            sh_status!("Saved interface at {}", loc.display())?;
137        } else if shell::is_json() {
138            print_json_object(json_abis)?;
139        } else {
140            sh_print!("{source}")?;
141        }
142        Ok(())
143    }
144}
145
146/// Reserves a declaration name across all generated interfaces and libraries.
147fn unique_declaration_name(
148    name: &str,
149    declarations: &mut HashSet<String>,
150    abi: Option<&JsonAbi>,
151) -> String {
152    let mut available = |candidate: &str| {
153        !abi.is_some_and(|abi| abi.functions.contains_key(candidate))
154            && declarations.insert(candidate.to_owned())
155    };
156    if available(name) {
157        return name.to_owned();
158    }
159    for suffix in 1.. {
160        let candidate = format!("{name}_{suffix}");
161        if available(&candidate) {
162            return candidate;
163        }
164    }
165    unreachable!()
166}
167
168/// Visits internal types, including nested tuples, in every ABI parameter.
169fn visit_abi_types(abi: &mut JsonAbi, visit: &mut impl FnMut(&mut InternalType)) {
170    if let Some(constructor) = abi.constructor_mut() {
171        visit_param_types(&mut constructor.inputs, visit);
172    }
173    for function in abi.functions_mut() {
174        visit_param_types(&mut function.inputs, visit);
175        visit_param_types(&mut function.outputs, visit);
176    }
177    for error in abi.errors_mut() {
178        visit_param_types(&mut error.inputs, visit);
179    }
180    for event in abi.events_mut() {
181        for param in &mut event.inputs {
182            if let Some(ty) = &mut param.internal_type {
183                visit(ty);
184            }
185            visit_param_types(&mut param.components, visit);
186        }
187    }
188}
189
190fn visit_param_types(params: &mut [Param], visit: &mut impl FnMut(&mut InternalType)) {
191    for param in params {
192        if let Some(ty) = &mut param.internal_type {
193            visit(ty);
194        }
195        visit_param_types(&mut param.components, visit);
196    }
197}
198
199/// Load the ABI from a file.
200pub(crate) fn load_abi_from_file(path: &str) -> Result<JsonAbi> {
201    let file = std::fs::read_to_string(path).wrap_err("unable to read abi file")?;
202    let obj: ContractObject = serde_json::from_str(&file)?;
203    obj.abi.ok_or_else(|| eyre::eyre!("could not find ABI in file {path}"))
204}
205
206/// Load the ABI and name from the artifact of a locally compiled contract.
207fn load_abi_from_artifact(path_or_contract: &str) -> Result<(JsonAbi, String)> {
208    let config = load_config()?;
209    let mut project = config.project()?;
210    project.no_artifacts = true;
211    let compiler = ProjectCompiler::new().quiet(true);
212
213    let contract = PathOrContractInfo::from_str(path_or_contract)?;
214    let target_path = find_target_path(&project, &contract)?;
215    let output = compile_abi_project(&mut project, compiler.files([target_path.clone()]))?;
216
217    let (abi, name) = ContractsByArtifact::from(output)
218        .find_abi_by_name_or_src_path(contract.name().unwrap_or(&target_path.to_string_lossy()))
219        .ok_or_else(|| eyre::eyre!("Failed to fetch lossless ABI"))?;
220    Ok((abi, contract.name().unwrap_or(&name).to_string()))
221}