Skip to main content

forge_verify/
verify.rs

1//! The `forge verify-bytecode` command.
2
3use crate::{
4    RetryArgs,
5    etherscan::EtherscanVerificationProvider,
6    provider::{
7        ExternalVerificationContext, VerificationContext, VerificationProvider,
8        VerificationProviderType,
9    },
10    sourcify::SourcifyVerificationProvider,
11    utils::wrap_verifier_url_error,
12};
13use alloy_primitives::{Address, TxHash, map::HashSet};
14use alloy_provider::Provider;
15use clap::{Parser, ValueEnum, ValueHint};
16use eyre::{Context, Result};
17use foundry_cli::{
18    opts::{EtherscanOpts, RpcOpts},
19    utils::{self, LoadConfig},
20};
21use foundry_common::{ContractsByArtifact, compile::ProjectCompiler};
22use foundry_compilers::{artifacts::EvmVersion, compilers::solc::Solc, info::ContractInfo};
23use foundry_config::{
24    Chain, Config, SolcReq,
25    figment::{
26        Error, Metadata, Profile, Provider as FigmentProvider,
27        value::{Dict, Map, Value},
28    },
29    impl_figment_convert, impl_figment_convert_cast,
30};
31use itertools::Itertools;
32use reqwest::{Client, StatusCode, Url};
33use semver::BuildMetadata;
34use serde::Deserialize;
35use std::{path::PathBuf, time::Duration};
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38enum VerifierCredentialProbe {
39    Accepted,
40    InvalidApiKey,
41    Inconclusive,
42}
43
44#[derive(Debug, Deserialize)]
45struct EtherscanProbeResponse {
46    status: String,
47    result: Option<serde_json::Value>,
48}
49
50fn verifier_credential_probe_query(api_key: Option<&str>) -> Vec<(&'static str, String)> {
51    let mut query = vec![
52        ("module", "contract".to_string()),
53        ("action", "getabi".to_string()),
54        ("address", Address::ZERO.to_string()),
55    ];
56    if let Some(api_key) = api_key {
57        query.push(("apikey", api_key.to_string()));
58    }
59    query
60}
61
62fn classify_verifier_credential_response(
63    status: StatusCode,
64    body: &str,
65) -> VerifierCredentialProbe {
66    let lower = body.to_lowercase();
67    if lower.contains("invalid api key") || lower.contains("invalid_api_key") {
68        return VerifierCredentialProbe::InvalidApiKey;
69    }
70
71    if lower.contains("contract source code not verified")
72        || lower.contains("contract not found")
73        || lower.contains("contract was not found")
74    {
75        return VerifierCredentialProbe::Accepted;
76    }
77
78    if status == StatusCode::UNAUTHORIZED {
79        return VerifierCredentialProbe::InvalidApiKey;
80    }
81
82    if !status.is_success()
83        || lower.contains("max rate limit reached")
84        || lower.contains("sorry, you have been blocked")
85        || lower.contains("checking if the site connection is secure")
86    {
87        return VerifierCredentialProbe::Inconclusive;
88    }
89
90    match serde_json::from_str::<EtherscanProbeResponse>(body) {
91        Ok(resp) if resp.status == "1" => VerifierCredentialProbe::Accepted,
92        Ok(resp) => resp
93            .result
94            .and_then(|result| result.as_str().map(str::to_lowercase))
95            .map(|result| {
96                if result.contains("invalid api key") || result.contains("invalid_api_key") {
97                    VerifierCredentialProbe::InvalidApiKey
98                } else if result.contains("max rate limit reached") {
99                    VerifierCredentialProbe::Inconclusive
100                } else if result.contains("contract source code not verified")
101                    || result.contains("contract not found")
102                    || result.contains("contract was not found")
103                {
104                    VerifierCredentialProbe::Accepted
105                } else {
106                    VerifierCredentialProbe::Inconclusive
107                }
108            })
109            .unwrap_or(VerifierCredentialProbe::Inconclusive),
110        Err(_) => VerifierCredentialProbe::Inconclusive,
111    }
112}
113
114fn parse_http_verifier_url(url: &str, label: &str) -> Result<Url> {
115    let url = Url::parse(url).wrap_err_with(|| format!("invalid {label} URL `{url}`"))?;
116    if !matches!(url.scheme(), "http" | "https") {
117        eyre::bail!("invalid {label} URL `{url}`: URL scheme must be http or https");
118    }
119    Ok(url)
120}
121
122async fn probe_verifier_credentials(
123    url: Url,
124    api_key: Option<&str>,
125) -> Result<VerifierCredentialProbe, reqwest::Error> {
126    let resp = Client::new()
127        .get(url)
128        .query(&verifier_credential_probe_query(api_key))
129        .timeout(Duration::from_secs(10))
130        .send()
131        .await?;
132    let status = resp.status();
133    let body = resp.text().await?;
134    Ok(classify_verifier_credential_response(status, &body))
135}
136
137/// The programming language used for smart contract development.
138///
139/// This enum represents the supported contract languages for verification.
140#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
141pub enum ContractLanguage {
142    /// Solidity programming language
143    Solidity,
144    /// Vyper programming language
145    Vyper,
146}
147
148/// Verification provider arguments
149#[derive(Clone, Debug, Default, Parser)]
150pub struct VerifierArgs {
151    /// The contract verification provider to use.
152    #[arg(long, help_heading = "Verifier options", value_enum)]
153    pub verifier: Option<VerificationProviderType>,
154
155    /// The verifier API KEY, if using a custom provider.
156    #[arg(long, help_heading = "Verifier options", env = "VERIFIER_API_KEY")]
157    pub verifier_api_key: Option<String>,
158
159    /// The verifier URL, if using a custom provider.
160    #[arg(long, help_heading = "Verifier options", env = "VERIFIER_URL")]
161    pub verifier_url: Option<String>,
162}
163
164impl VerifierArgs {
165    /// Returns the effective verifier type, defaulting to Sourcify if not explicitly set.
166    ///
167    /// Note: this is the *defaulted CLI value*, not the actually-selected provider after
168    /// considering `ETHERSCAN_API_KEY` / chain support. Use [`Self::resolve`] for that.
169    pub fn effective_type(&self) -> VerificationProviderType {
170        self.verifier.unwrap_or_default()
171    }
172
173    /// Returns true if `--verifier` was explicitly provided by the user.
174    pub const fn is_explicitly_set(&self) -> bool {
175        self.verifier.is_some()
176    }
177
178    /// Resolves the API key with consistent precedence: explicit `--verifier-api-key` first,
179    /// then the etherscan config key.
180    pub fn resolve_api_key<'a>(&'a self, etherscan_key: Option<&'a str>) -> Option<&'a str> {
181        self.verifier_api_key.as_deref().or(etherscan_key)
182    }
183
184    /// Makes a lightweight network call to validate that credentials are accepted by the verifier.
185    ///
186    /// `api_key` must be the already-merged API key (CLI `--verifier-api-key` takes
187    /// precedence over config), as returned by [`Self::resolve_api_key`].
188    pub async fn check_credentials(
189        &self,
190        api_key: Option<&str>,
191        chain: Chain,
192        config: &Config,
193    ) -> eyre::Result<()> {
194        let resolved = self.resolve(api_key, Some(chain));
195        match resolved {
196            VerificationProviderType::Etherscan
197            | VerificationProviderType::Blockscout
198            | VerificationProviderType::Oklink => {
199                let etherscan_opts =
200                    EtherscanOpts { key: api_key.map(str::to_owned), chain: Some(chain) };
201                let client = EtherscanVerificationProvider::default().client(
202                    &etherscan_opts,
203                    self,
204                    config,
205                )?;
206                match tokio::time::timeout(
207                    Duration::from_secs(10),
208                    probe_verifier_credentials(
209                        client.etherscan_api_url().clone(),
210                        client.api_key(),
211                    ),
212                )
213                .await
214                {
215                    Err(_) => {
216                        sh_warn!("verifier credential check timed out, proceeding anyway")?;
217                    }
218                    Ok(Ok(VerifierCredentialProbe::Accepted)) => {}
219                    Ok(Ok(VerifierCredentialProbe::InvalidApiKey)) => {
220                        eyre::bail!("verifier credential check failed: invalid API key");
221                    }
222                    Ok(Ok(VerifierCredentialProbe::Inconclusive) | Err(_)) => {
223                        sh_warn!("verifier credential check inconclusive, proceeding anyway")?;
224                    }
225                }
226            }
227            VerificationProviderType::Custom => {
228                // Custom verifiers may return Etherscan-shaped responses (HTTP 200 with a JSON
229                // body) or standard HTTP auth errors (401/403). Check both.
230                if let Some(url) = &self.verifier_url {
231                    let url = parse_http_verifier_url(url, "verifier")?;
232                    match probe_verifier_credentials(url, api_key).await {
233                        Err(_) => {
234                            sh_warn!("verifier credential check failed, proceeding anyway")?;
235                        }
236                        Ok(
237                            VerifierCredentialProbe::Accepted
238                            | VerifierCredentialProbe::Inconclusive,
239                        ) => {}
240                        Ok(VerifierCredentialProbe::InvalidApiKey) => {
241                            eyre::bail!("verifier credential check failed: invalid API key");
242                        }
243                    }
244                }
245            }
246            VerificationProviderType::Sourcify => {
247                // Only probe custom URLs; the default public endpoint is assumed reachable.
248                if let Some(url) = &self.verifier_url {
249                    let url = parse_http_verifier_url(url, "Sourcify")?;
250                    match Client::new()
251                        .get(url.clone())
252                        .timeout(Duration::from_secs(10))
253                        .send()
254                        .await
255                    {
256                        Err(_) => {
257                            sh_warn!(
258                                "Sourcify URL `{url}` could not be reached, proceeding anyway"
259                            )?;
260                        }
261                        Ok(resp) => {
262                            let status = resp.status();
263                            if !status.is_success() && status != StatusCode::NOT_FOUND {
264                                sh_warn!(
265                                    "Sourcify URL `{url}` returned HTTP {status}, proceeding anyway"
266                                )?;
267                            }
268                        }
269                    }
270                }
271            }
272        }
273        Ok(())
274    }
275
276    /// Resolves the actual verification provider that will be used at runtime, taking into
277    /// account the explicit `--verifier`, the presence of `ETHERSCAN_API_KEY`, and whether the
278    /// target chain has a known Etherscan API URL.
279    ///
280    /// Resolution rules (mirrors [`VerificationProviderType::client`]):
281    /// 1. If `--verifier` was explicitly set, that wins.
282    /// 2. Otherwise, if an Etherscan API key is set AND the chain is supported (or a custom
283    ///    `--verifier-url` is provided), use Etherscan.
284    /// 3. Otherwise, fall back to Sourcify.
285    pub fn resolve(
286        &self,
287        etherscan_key: Option<&str>,
288        chain: Option<Chain>,
289    ) -> VerificationProviderType {
290        if let Some(v) = self.verifier {
291            return v;
292        }
293        let has_key = etherscan_key.is_some_and(|k| !k.is_empty());
294        // Custom-Sourcify chains (e.g. Tempo) register Sourcify-compatible URLs under
295        // etherscan_urls() but are NOT real Etherscan chains. Skip the implicit-Etherscan path
296        // entirely for them; the caller must use `--verifier etherscan` explicitly to override.
297        if has_key && !chain.is_some_and(|c| c.is_custom_sourcify()) {
298            let chain_has_etherscan_url = chain.is_none_or(|c| c.etherscan_urls().is_some());
299            if chain_has_etherscan_url || self.verifier_url.is_some() {
300                return VerificationProviderType::Etherscan;
301            }
302        }
303        VerificationProviderType::Sourcify
304    }
305}
306
307/// CLI arguments for `forge verify-contract`.
308#[derive(Clone, Debug, Parser)]
309pub struct VerifyArgs {
310    /// The address of the contract to verify.
311    pub address: Address,
312
313    /// The contract identifier in the form `<path>:<contractname>`.
314    pub contract: Option<ContractInfo>,
315
316    /// The ABI-encoded constructor arguments. Only for Etherscan.
317    #[arg(
318        long,
319        conflicts_with = "constructor_args_path",
320        value_name = "ARGS",
321        visible_alias = "encoded-constructor-args"
322    )]
323    pub constructor_args: Option<String>,
324
325    /// The path to a file containing the constructor arguments.
326    #[arg(long, value_hint = ValueHint::FilePath, value_name = "PATH")]
327    pub constructor_args_path: Option<PathBuf>,
328
329    /// Try to extract constructor arguments from on-chain creation code.
330    #[arg(long)]
331    pub guess_constructor_args: bool,
332
333    /// The hash of the transaction which created the contract. Optional for Sourcify.
334    #[arg(long)]
335    pub creation_transaction_hash: Option<TxHash>,
336
337    /// The `solc` version to use to build the smart contract.
338    #[arg(long, value_name = "VERSION")]
339    pub compiler_version: Option<String>,
340
341    /// The compilation profile to use to build the smart contract.
342    #[arg(long, value_name = "PROFILE_NAME")]
343    pub compilation_profile: Option<String>,
344
345    /// The number of optimization runs used to build the smart contract.
346    #[arg(long, visible_alias = "optimizer-runs", value_name = "NUM")]
347    pub num_of_optimizations: Option<usize>,
348
349    /// Flatten the source code before verifying.
350    #[arg(long)]
351    pub flatten: bool,
352
353    /// Do not compile the flattened smart contract before verifying (if --flatten is passed).
354    #[arg(short, long)]
355    pub force: bool,
356
357    /// Do not check if the contract is already verified before verifying.
358    #[arg(long)]
359    pub skip_is_verified_check: bool,
360
361    /// Wait for verification result after submission.
362    #[arg(long)]
363    pub watch: bool,
364
365    /// Print the submission ID and URL to stdout. Only set for standalone `verify-contract`, so
366    /// embedded verification (`forge create`/`script --verify`) keeps stdout clean.
367    #[arg(skip)]
368    pub print_submission_result_to_stdout: bool,
369
370    /// Set pre-linked libraries.
371    #[arg(long, help_heading = "Linker options", env = "DAPP_LIBRARIES")]
372    pub libraries: Vec<String>,
373
374    /// The project's root path.
375    ///
376    /// By default root of the Git repository, if in one,
377    /// or the current working directory.
378    #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
379    pub root: Option<PathBuf>,
380
381    /// Prints the standard json compiler input.
382    ///
383    /// The standard json compiler input can be used to manually submit contract verification in
384    /// the browser.
385    #[arg(long, conflicts_with = "flatten")]
386    pub show_standard_json_input: bool,
387
388    /// Use the Yul intermediate representation compilation pipeline.
389    #[arg(long)]
390    pub via_ir: bool,
391
392    /// The Etherscan license type code or SPDX identifier to include with the verification
393    /// request.
394    ///
395    /// Accepts either an Etherscan numeric license code (see
396    /// <https://etherscan.io/contract-license-types>) or a common SPDX identifier such as
397    /// `MIT`, `Apache-2.0`, `GPL-3.0-or-later`, or `AGPL-3.0-or-later`. Only used for
398    /// Etherscan-style verifiers.
399    #[arg(
400        long,
401        value_name = "LICENSE",
402        help_heading = "Verifier options",
403        value_parser = parse_etherscan_license_type,
404    )]
405    pub license_type: Option<String>,
406
407    /// The EVM version to use.
408    ///
409    /// Overrides the version specified in the config.
410    #[arg(long)]
411    pub evm_version: Option<EvmVersion>,
412
413    /// Do not auto-detect the `solc` version.
414    #[arg(long, help_heading = "Compiler options")]
415    pub no_auto_detect: bool,
416
417    /// Specify the solc version, or a path to a local solc, to build with.
418    ///
419    /// Valid values are in the format `x.y.z`, `solc:x.y.z` or `path/to/solc`.
420    #[arg(long = "use", help_heading = "Compiler options", value_name = "SOLC_VERSION")]
421    pub use_solc: Option<String>,
422
423    #[command(flatten)]
424    pub etherscan: EtherscanOpts,
425
426    #[command(flatten)]
427    pub rpc: RpcOpts,
428
429    #[command(flatten)]
430    pub retry: RetryArgs,
431
432    #[command(flatten)]
433    pub verifier: VerifierArgs,
434
435    /// The contract language (`solidity` or `vyper`).
436    ///
437    /// Defaults to `solidity` if none provided.
438    #[arg(long, value_enum)]
439    pub language: Option<ContractLanguage>,
440}
441
442pub fn parse_etherscan_license_type(value: &str) -> Result<String, String> {
443    let value = value.trim();
444    if value.is_empty() {
445        return Err("license type cannot be empty".into());
446    }
447
448    if let Ok(code) = value.parse::<u32>() {
449        return Ok(code.to_string());
450    }
451
452    let normalized = normalize_license_type(value);
453    let code = match normalized.as_str() {
454        "none" | "no-license" | "unlicensed" => 1,
455        "unlicense" | "the-unlicense" => 2,
456        "mit" | "mit-license" => 3,
457        "gpl-2.0" | "gpl-2.0+" | "gpl-2.0-only" | "gpl-2.0-or-later" | "gplv2" | "gnu-gplv2" => 4,
458        "gpl-3.0" | "gpl-3.0+" | "gpl-3.0-only" | "gpl-3.0-or-later" | "gplv3" | "gnu-gplv3" => 5,
459        "lgpl-2.1" | "lgpl-2.1+" | "lgpl-2.1-only" | "lgpl-2.1-or-later" | "lgplv2.1"
460        | "gnu-lgplv2.1" => 6,
461        "lgpl-3.0" | "lgpl-3.0+" | "lgpl-3.0-only" | "lgpl-3.0-or-later" | "lgplv3"
462        | "gnu-lgplv3" => 7,
463        "bsd-2-clause" => 8,
464        "bsd-3-clause" => 9,
465        "mpl-2.0" => 10,
466        "osl-3.0" => 11,
467        "apache-2.0" | "apache-license-2.0" => 12,
468        "agpl-3.0" | "agpl-3.0+" | "agpl-3.0-only" | "agpl-3.0-or-later" | "agplv3"
469        | "gnu-agplv3" => 13,
470        "bsl-1.1" | "busl-1.1" | "business-source-license-1.1" => 14,
471        _ => {
472            return Err(format!(
473                "unsupported Etherscan license type `{value}`; expected a numeric code or a \
474                 supported SPDX identifier such as MIT, Apache-2.0, GPL-3.0-or-later, or \
475                 AGPL-3.0-or-later"
476            ));
477        }
478    };
479
480    Ok(code.to_string())
481}
482
483fn normalize_license_type(value: &str) -> String {
484    let mut normalized = String::with_capacity(value.len());
485    let mut last_was_dash = false;
486
487    for ch in value.trim().chars() {
488        let ch = match ch {
489            '_' | ' ' | '\t' | '\n' | '\r' => '-',
490            _ => ch.to_ascii_lowercase(),
491        };
492
493        if ch == '-' {
494            if !last_was_dash {
495                normalized.push(ch);
496            }
497            last_was_dash = true;
498        } else {
499            normalized.push(ch);
500            last_was_dash = false;
501        }
502    }
503
504    normalized.trim_matches('-').to_string()
505}
506
507impl_figment_convert!(VerifyArgs);
508
509impl FigmentProvider for VerifyArgs {
510    fn metadata(&self) -> Metadata {
511        Metadata::named("Verify Provider")
512    }
513
514    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
515        let mut dict = self.etherscan.dict();
516        dict.extend(self.rpc.dict());
517
518        if let Some(root) = self.root.as_ref() {
519            dict.insert("root".to_string(), Value::serialize(root)?);
520        }
521        if let Some(optimizer_runs) = self.num_of_optimizations {
522            dict.insert("optimizer".to_string(), Value::serialize(true)?);
523            dict.insert("optimizer_runs".to_string(), Value::serialize(optimizer_runs)?);
524        }
525        if let Some(evm_version) = self.evm_version {
526            dict.insert("evm_version".to_string(), Value::serialize(evm_version)?);
527        }
528        if self.via_ir {
529            dict.insert("via_ir".to_string(), Value::serialize(self.via_ir)?);
530        }
531
532        if self.no_auto_detect {
533            dict.insert("auto_detect_solc".to_string(), Value::serialize(false)?);
534        }
535
536        if let Some(ref solc) = self.use_solc {
537            let solc = solc.trim_start_matches("solc:");
538            dict.insert("solc".to_string(), Value::serialize(solc)?);
539        }
540
541        if let Some(api_key) = &self.verifier.verifier_api_key {
542            dict.insert("etherscan_api_key".into(), api_key.as_str().into());
543        }
544
545        Ok(Map::from([(Config::selected_profile(), dict)]))
546    }
547}
548
549struct ProviderRun {
550    label: VerificationProviderType,
551    args: VerifyArgs,
552    provider: Box<dyn VerificationProvider>,
553    /// If true, a failed run fails the command; otherwise the failure is logged as a warning.
554    required: bool,
555}
556
557#[derive(Clone)]
558enum RunContext {
559    Local(Box<VerificationContext>),
560    External(Box<ExternalVerificationContext>),
561}
562
563impl VerifyArgs {
564    /// Run the verify command to submit the contract's source code for verification on etherscan
565    pub async fn run(self) -> Result<()> {
566        let config = self.load_config()?;
567        let context = self.resolve_context().await?;
568        self.run_with_resolved_context(RunContext::Local(Box::new(context)), config).await
569    }
570
571    /// Runs verification using caller-provided Standard JSON input.
572    pub async fn run_with_external_context(
573        self,
574        context: ExternalVerificationContext,
575    ) -> Result<()> {
576        self.validate_external_args()?;
577        eyre::ensure!(
578            !context.target.is_empty()
579                && context.target.len() <= 8193
580                && context.target.is_ascii()
581                && !context.target.bytes().any(|byte| byte.is_ascii_control()),
582            "external contract identifier must contain bounded printable ASCII"
583        );
584        let config = context.config.clone();
585        self.run_with_resolved_context(RunContext::External(Box::new(context)), config).await
586    }
587
588    fn validate_external_args(&self) -> Result<()> {
589        eyre::ensure!(!self.flatten, "flattening is unsupported for external verification");
590        eyre::ensure!(
591            !matches!(self.language, Some(ContractLanguage::Vyper)),
592            "Vyper is unsupported for external verification"
593        );
594        eyre::ensure!(
595            !self.guess_constructor_args,
596            "constructor argument guessing is unsupported for external verification"
597        );
598        eyre::ensure!(
599            self.constructor_args_path.is_none(),
600            "constructor args paths are unsupported for external verification; provide encoded constructor args"
601        );
602        Ok(())
603    }
604
605    async fn run_with_resolved_context(
606        mut self,
607        context: RunContext,
608        config: Config,
609    ) -> Result<()> {
610        if self.guess_constructor_args && config.get_rpc_url().is_none() {
611            eyre::bail!(
612                "You have to provide a valid RPC URL to use --guess-constructor-args feature"
613            );
614        }
615
616        let chain = match config.get_rpc_url() {
617            Some(_) => {
618                let provider = utils::get_provider(&config)?;
619                utils::get_chain(config.chain, provider).await?
620            }
621            None => config.chain.unwrap_or_default(),
622        };
623
624        // Set Etherscan options.
625        self.etherscan.chain = Some(chain);
626        // `get_etherscan_config_with_chain` returns None for chains with no known Etherscan API
627        // URL (even when a key was explicitly passed), because `ResolvedEtherscanConfig::create`
628        // requires `chain.etherscan_urls()`. Fall back to the raw `etherscan_api_key` from config
629        // so that the key survives for warning/fallback logic in `client()`.
630        let config_key = match config.get_etherscan_config_with_chain(Some(chain)) {
631            Ok(config) => config.map(|config| config.key),
632            // A user-selected URL does not depend on the optional configured endpoint. Explicit
633            // Sourcify likewise must not fail because an unrelated Etherscan fallback is invalid.
634            Err(_)
635                if self.verifier.verifier_url.is_some()
636                    || self.verifier.verifier.is_some_and(|provider| provider.is_sourcify()) =>
637            {
638                None
639            }
640            Err(err) => return Err(err.into()),
641        }
642        .or_else(|| config.etherscan_api_key.clone());
643        self.etherscan.key =
644            self.verifier.resolve_api_key(config_key.as_deref()).map(str::to_owned);
645
646        // Capture whether the user explicitly provided a verifier URL *before* any auto-injection.
647        // This is passed to `client()` so that an auto-injected Sourcify URL does not look like a
648        // user-supplied Etherscan-compatible URL and cause the wrong provider to be selected.
649        let had_user_verifier_url = self.verifier.verifier_url.is_some();
650
651        // Resolve provider BEFORE URL injection so that the auto-injected Sourcify URL cannot
652        // influence routing. For custom-Sourcify chains (e.g. Tempo), etherscan_urls() returns
653        // Some but is_custom_sourcify() excludes them from the Etherscan path in resolve().
654        let etherscan_key = self.etherscan.key();
655        let resolved = self.verifier.resolve(etherscan_key.as_deref(), self.etherscan.chain);
656
657        // For chains with Sourcify-compatible APIs, inject their URL only when we've resolved to
658        // Sourcify and the user did not already supply a --verifier-url.
659        if resolved.is_sourcify()
660            && !had_user_verifier_url
661            && let Some(url) = sourcify_api_url(chain)
662        {
663            self.verifier.verifier_url = Some(url);
664        }
665
666        if self.show_standard_json_input {
667            match &context {
668                RunContext::Local(context) => {
669                    let args = EtherscanVerificationProvider::default()
670                        .create_verify_request(&self, context)
671                        .await?;
672                    sh_println!("{}", args.source)?;
673                }
674                RunContext::External(context) => {
675                    sh_println!("{}", serde_json::to_string(&context.standard_json_input)?)?;
676                }
677            }
678            return Ok(());
679        }
680
681        let verifier_url = self.verifier.verifier_url.clone();
682        sh_status!("Start verifying contract `{}` deployed on {chain}", self.address)?;
683        if let Some(version) = &self.evm_version {
684            sh_status!("EVM version: {version}")?;
685        }
686        if let Some(version) = &self.compiler_version {
687            sh_status!("Compiler version: {version}")?;
688        }
689        if let Some(optimizations) = &self.num_of_optimizations {
690            sh_status!("Optimizations:    {optimizations}")?
691        }
692        if let Some(args) = &self.constructor_args
693            && !args.is_empty()
694        {
695            sh_status!("Constructor args: {args}")?
696        }
697        let using_etherscan = resolved.is_etherscan();
698        let runs =
699            self.collect_runs(chain, etherscan_key.as_deref(), resolved, had_user_verifier_url)?;
700
701        // Submit every provider before polling any of them
702        let mut required_err = None;
703        let mut pending_check = None;
704        for ProviderRun { label, args, mut provider, required } in runs {
705            sh_status!("\nVerifying on {label}...")?;
706            let watch = args.watch;
707            let submission = match context.clone() {
708                RunContext::Local(context) => provider.submit(args, *context).await,
709                RunContext::External(context) => provider.submit_external(args, *context).await,
710            };
711            match submission {
712                Ok(check_args) => {
713                    if required
714                        && watch
715                        && let Some(check_args) = check_args
716                    {
717                        pending_check = Some((label, provider, check_args));
718                    }
719                }
720                Err(err) => {
721                    if required {
722                        required_err = Some(wrap_verifier_url_error(
723                            err,
724                            verifier_url.as_deref(),
725                            using_etherscan,
726                        ));
727                    } else {
728                        sh_warn!("{label} verification failed: {err}")?;
729                    }
730                }
731            }
732        }
733
734        // Poll the primary submission for completion
735        if required_err.is_none()
736            && let Some((label, provider, check_args)) = pending_check
737        {
738            sh_status!("\nWaiting for {label} verification result...")?;
739            if let Err(err) = provider.check(check_args).await {
740                required_err =
741                    Some(wrap_verifier_url_error(err, verifier_url.as_deref(), using_etherscan));
742            }
743        }
744
745        required_err.map_or(Ok(()), Err)
746    }
747
748    /// Plans the set of verification submissions to run for this invocation.
749    ///
750    /// `resolved` is the decision from [`VerifierArgs::resolve`] for the primary verifier.
751    /// Sourcify is added as an auxiliary run whenever the primary is not Sourcify.
752    fn collect_runs(
753        &self,
754        chain: Chain,
755        etherscan_key: Option<&str>,
756        resolved: VerificationProviderType,
757        had_user_verifier_url: bool,
758    ) -> Result<Vec<ProviderRun>> {
759        let mut runs = Vec::new();
760
761        let primary_provider = resolved.client(
762            etherscan_key,
763            self.etherscan.chain,
764            had_user_verifier_url,
765            self.verifier.is_explicitly_set(),
766        )?;
767        let primary_is_sourcify = resolved.is_sourcify();
768        runs.push(ProviderRun {
769            label: resolved,
770            args: self.clone(),
771            provider: primary_provider,
772            required: true,
773        });
774
775        // Skip the auxiliary Sourcify submission when the user appears to be using a
776        // non-public setup: an explicit `--verifier-url`, `--verifier custom`, or a
777        // local/dev chain.
778        let is_private_setup = had_user_verifier_url || resolved.is_custom() || is_dev_chain(chain);
779        if !primary_is_sourcify && !is_private_setup {
780            let mut args = self.clone();
781            args.verifier.verifier = Some(VerificationProviderType::Sourcify);
782            args.verifier.verifier_api_key = None;
783            // For chains with Sourcify-compatible APIs, use the chain's URL from etherscan_urls
784            // Otherwise, drop the URL so Sourcify falls back to its default.
785            args.verifier.verifier_url = sourcify_api_url(chain);
786            args.watch = false;
787            runs.push(ProviderRun {
788                label: VerificationProviderType::Sourcify,
789                args,
790                provider: Box::<SourcifyVerificationProvider>::default(),
791                required: false,
792            });
793        }
794
795        Ok(runs)
796    }
797
798    /// Returns the configured verification provider
799    pub fn verification_provider(&self) -> Result<Box<dyn VerificationProvider>> {
800        self.verifier.effective_type().client(
801            self.etherscan.key().as_deref(),
802            self.etherscan.chain,
803            self.verifier.verifier_url.is_some(),
804            self.verifier.is_explicitly_set(),
805        )
806    }
807
808    /// Resolves [VerificationContext] object either from entered contract name or by trying to
809    /// match bytecode located at given address.
810    pub async fn resolve_context(&self) -> Result<VerificationContext> {
811        let mut config = self.load_config()?;
812        config.libraries.extend(self.libraries.clone());
813
814        let project = config.project()?;
815
816        if let Some(ref contract) = self.contract {
817            let contract_path = if let Some(ref path) = contract.path {
818                project.root().join(PathBuf::from(path))
819            } else {
820                project.find_contract_path(&contract.name)?
821            };
822
823            let cache = project.read_cache_file().ok();
824
825            let mut version = if let Some(ref version) = self.compiler_version {
826                version.trim_start_matches('v').parse()?
827            } else if let Some(ref solc) = config.solc {
828                match solc {
829                    SolcReq::Version(version) => version.to_owned(),
830                    SolcReq::Local(solc) => Solc::new(solc)?.version,
831                }
832            } else if let Some(entry) =
833                cache.as_ref().and_then(|cache| cache.files.get(&contract_path).cloned())
834            {
835                let unique_versions = entry
836                    .artifacts
837                    .get(&contract.name)
838                    .map(|artifacts| artifacts.keys().collect::<HashSet<_>>())
839                    .unwrap_or_default();
840
841                if unique_versions.is_empty() {
842                    eyre::bail!(
843                        "No matching artifact found for {}. This could be due to:\n\
844                        - Compiler version mismatch - the contract was compiled with a different Solidity version than what's being used for verification",
845                        contract.name
846                    );
847                } else if unique_versions.len() > 1 {
848                    warn!(
849                        "Ambiguous compiler versions found in cache: {}",
850                        unique_versions.iter().join(", ")
851                    );
852                    eyre::bail!(
853                        "Compiler version has to be set in `foundry.toml`. If the project was not deployed with foundry, specify the version through `--compiler-version` flag."
854                    );
855                }
856
857                unique_versions.into_iter().next().unwrap().to_owned()
858            } else {
859                eyre::bail!(
860                    "If cache is disabled, compiler version must be either provided with `--compiler-version` option or set in foundry.toml"
861                );
862            };
863
864            let settings = if let Some(profile) = &self.compilation_profile {
865                if profile == "default" {
866                    &project.settings
867                } else if let Some(settings) = project.additional_settings.get(profile.as_str()) {
868                    settings
869                } else {
870                    eyre::bail!("Unknown compilation profile: {}", profile);
871                }
872            } else if let Some((cache, entry)) = cache
873                .as_ref()
874                .and_then(|cache| Some((cache, cache.files.get(&contract_path)?.clone())))
875            {
876                let profiles = entry
877                    .artifacts
878                    .get(&contract.name)
879                    .and_then(|artifacts| {
880                        let mut cached_artifacts = artifacts.get(&version);
881                        // If we try to verify with specific build version and no cached artifacts
882                        // found, then check if we have artifacts cached for same version but
883                        // without any build metadata.
884                        // This could happen when artifacts are built / cached
885                        // with a version like `0.8.20` but verify is using a compiler-version arg
886                        // as `0.8.20+commit.a1b79de6`.
887                        // See <https://github.com/foundry-rs/foundry/issues/9510>.
888                        if cached_artifacts.is_none() && version.build != BuildMetadata::EMPTY {
889                            version.build = BuildMetadata::EMPTY;
890                            cached_artifacts = artifacts.get(&version);
891                        }
892                        cached_artifacts
893                    })
894                    .map(|artifacts| artifacts.keys().collect::<HashSet<_>>())
895                    .unwrap_or_default();
896
897                if profiles.is_empty() {
898                    eyre::bail!(
899                        "No matching artifact found for {} with compiler version {}. This could be due to:\n\
900                        - Compiler version mismatch - the contract was compiled with a different Solidity version",
901                        contract.name,
902                        version
903                    );
904                } else if profiles.len() > 1 {
905                    eyre::bail!(
906                        "Ambiguous compilation profiles found in cache: {}, please specify the profile through `--compilation-profile` flag",
907                        profiles.iter().join(", ")
908                    );
909                }
910
911                let profile = profiles.into_iter().next().unwrap().to_owned();
912                cache.profiles.get(&profile).expect("must be present")
913            } else if project.additional_settings.is_empty() {
914                &project.settings
915            } else {
916                eyre::bail!(
917                    "If cache is disabled, compilation profile must be provided with `--compilation-profile` option or set in foundry.toml"
918                );
919            };
920
921            VerificationContext::new(
922                contract_path,
923                contract.name.clone(),
924                version,
925                config,
926                settings.clone(),
927            )
928        } else {
929            if config.get_rpc_url().is_none() {
930                eyre::bail!("You have to provide a contract name or a valid RPC URL");
931            }
932            let provider = utils::get_provider(&config)?;
933            let code = provider.get_code_at(self.address).await?;
934
935            let output = ProjectCompiler::new().quiet(true).compile(&project)?;
936            let contracts = ContractsByArtifact::new(
937                output.artifact_ids().map(|(id, artifact)| (id, artifact.clone().into())),
938            );
939
940            let Some((artifact_id, _)) = contracts.find_by_deployed_code_exact(&code) else {
941                eyre::bail!(format!(
942                    "Bytecode at {} does not match any local contracts",
943                    self.address
944                ));
945            };
946
947            let settings = project
948                .settings_profiles()
949                .find_map(|(name, settings)| {
950                    (name == artifact_id.profile.as_str()).then_some(settings)
951                })
952                .expect("must be present");
953
954            VerificationContext::new(
955                artifact_id.source.clone(),
956                artifact_id.name.split('.').next().unwrap().to_owned(),
957                artifact_id.version.clone(),
958                config,
959                settings.clone(),
960            )
961        }
962    }
963
964    /// Detects the language for verification from source file extension, if none provided.
965    pub fn detect_language(&self, ctx: &VerificationContext) -> ContractLanguage {
966        self.language.unwrap_or_else(|| {
967            match ctx.target_path.extension().and_then(|e| e.to_str()) {
968                Some("vy") => ContractLanguage::Vyper,
969                _ => ContractLanguage::Solidity,
970            }
971        })
972    }
973}
974
975/// Check verification status arguments
976#[derive(Clone, Debug, Parser)]
977pub struct VerifyCheckArgs {
978    /// The verification ID.
979    ///
980    /// For Etherscan - Submission GUID.
981    ///
982    /// For Sourcify - Verification Job ID.
983    pub id: String,
984
985    #[command(flatten)]
986    pub retry: RetryArgs,
987
988    #[command(flatten)]
989    pub etherscan: EtherscanOpts,
990
991    #[command(flatten)]
992    pub verifier: VerifierArgs,
993}
994
995impl_figment_convert_cast!(VerifyCheckArgs);
996
997impl VerifyCheckArgs {
998    /// Run the verify command to submit the contract's source code for verification on etherscan
999    pub async fn run(self) -> Result<()> {
1000        sh_status!("Checking verification status on {}", self.etherscan.chain.unwrap_or_default())?;
1001        self.verifier
1002            .effective_type()
1003            .client(
1004                self.etherscan.key().as_deref(),
1005                self.etherscan.chain,
1006                self.verifier.verifier_url.is_some(),
1007                self.verifier.is_explicitly_set(),
1008            )?
1009            .check(self)
1010            .await
1011    }
1012}
1013
1014impl FigmentProvider for VerifyCheckArgs {
1015    fn metadata(&self) -> Metadata {
1016        Metadata::named("Verify Check Provider")
1017    }
1018
1019    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
1020        let mut dict = self.etherscan.dict();
1021        if let Some(api_key) = &self.etherscan.key {
1022            dict.insert("etherscan_api_key".into(), api_key.as_str().into());
1023        }
1024
1025        Ok(Map::from([(Config::selected_profile(), dict)]))
1026    }
1027}
1028
1029/// Returns the Sourcify-compatible API URL for chains that have one registered in `etherscan_urls`.
1030///
1031/// Some chains register their Sourcify-compatible verification API under `etherscan_urls` in
1032/// alloy-chains. This function returns the properly formatted URL for such chains.
1033pub fn sourcify_api_url(chain: Chain) -> Option<String> {
1034    if chain.is_custom_sourcify() {
1035        chain.etherscan_urls().map(|(api_url, _)| {
1036            let api_url = api_url.trim_end_matches('/');
1037            format!("{api_url}/")
1038        })
1039    } else {
1040        None
1041    }
1042}
1043
1044/// Returns `true` for local/dev chains.
1045const fn is_dev_chain(chain: Chain) -> bool {
1046    use foundry_config::NamedChain;
1047    matches!(chain.named(), Some(NamedChain::Dev | NamedChain::AnvilHardhat | NamedChain::Cannon))
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053
1054    #[test]
1055    fn verifier_api_key_takes_precedence_over_etherscan_key() {
1056        let args = VerifierArgs { verifier_api_key: Some("explicit".into()), ..Default::default() };
1057        assert_eq!(args.resolve_api_key(Some("config")), Some("explicit"));
1058        assert_eq!(VerifierArgs::default().resolve_api_key(Some("config")), Some("config"));
1059    }
1060
1061    #[tokio::test]
1062    async fn external_context_rejects_control_characters_in_fqn() {
1063        let args =
1064            VerifyArgs::parse_from(["foundry-cli", "0xd8509bee9c9bf012282ad33aba0d87241baf5064"]);
1065        let context = ExternalVerificationContext {
1066            config: Config::default(),
1067            compiler_version: semver::Version::new(0, 8, 30),
1068            standard_json_input: std::sync::Arc::new(serde_json::json!({})),
1069            target: "A.sol:A\nforged".into(),
1070        };
1071        assert!(
1072            args.run_with_external_context(context)
1073                .await
1074                .unwrap_err()
1075                .to_string()
1076                .contains("printable")
1077        );
1078    }
1079
1080    #[test]
1081    fn external_run_context_clone_shares_standard_json() {
1082        let input = std::sync::Arc::new(serde_json::json!({"large": [1, 2, 3]}));
1083        let context = RunContext::External(Box::new(ExternalVerificationContext {
1084            config: Config::default(),
1085            compiler_version: semver::Version::new(0, 8, 30),
1086            standard_json_input: input.clone(),
1087            target: "A.sol:A".into(),
1088        }));
1089        let RunContext::External(context) = &context else { unreachable!() };
1090        let cloned = context.clone();
1091        assert!(std::sync::Arc::ptr_eq(&input, &cloned.standard_json_input));
1092    }
1093
1094    #[test]
1095    fn can_parse_verify_contract() {
1096        let args: VerifyArgs = VerifyArgs::parse_from([
1097            "foundry-cli",
1098            "0x0000000000000000000000000000000000000000",
1099            "src/Domains.sol:Domains",
1100            "--via-ir",
1101            "--license-type",
1102            "13",
1103        ]);
1104        assert!(args.via_ir);
1105        assert_eq!(args.license_type.as_deref(), Some("13"));
1106    }
1107
1108    #[test]
1109    fn can_parse_verify_contract_license_type_spdx() {
1110        let args: VerifyArgs = VerifyArgs::parse_from([
1111            "foundry-cli",
1112            "0x0000000000000000000000000000000000000000",
1113            "src/Domains.sol:Domains",
1114            "--license-type",
1115            "AGPL-3.0-or-later",
1116        ]);
1117        assert_eq!(args.license_type.as_deref(), Some("13"));
1118
1119        let args: VerifyArgs = VerifyArgs::parse_from([
1120            "foundry-cli",
1121            "0x0000000000000000000000000000000000000000",
1122            "src/Domains.sol:Domains",
1123            "--license-type",
1124            "MIT",
1125        ]);
1126        assert_eq!(args.license_type.as_deref(), Some("3"));
1127
1128        let args: VerifyArgs = VerifyArgs::parse_from([
1129            "foundry-cli",
1130            "0x0000000000000000000000000000000000000000",
1131            "src/Domains.sol:Domains",
1132            "--license-type",
1133            "apache 2.0",
1134        ]);
1135        assert_eq!(args.license_type.as_deref(), Some("12"));
1136    }
1137
1138    #[test]
1139    fn verify_contract_license_type_is_case_insensitive() {
1140        for variant in ["mit", "MIT", "Mit", "mIt"] {
1141            let args: VerifyArgs = VerifyArgs::parse_from([
1142                "foundry-cli",
1143                "0x0000000000000000000000000000000000000000",
1144                "src/Domains.sol:Domains",
1145                "--license-type",
1146                variant,
1147            ]);
1148            assert_eq!(args.license_type.as_deref(), Some("3"), "input: {variant}");
1149        }
1150    }
1151
1152    #[test]
1153    fn verify_contract_license_type_accepts_numeric_codes() {
1154        for (code, expected) in [("1", "1"), ("14", "14"), ("15", "15")] {
1155            let args: VerifyArgs = VerifyArgs::parse_from([
1156                "foundry-cli",
1157                "0x0000000000000000000000000000000000000000",
1158                "src/Domains.sol:Domains",
1159                "--license-type",
1160                code,
1161            ]);
1162            assert_eq!(args.license_type.as_deref(), Some(expected));
1163        }
1164    }
1165
1166    #[test]
1167    fn errors_on_invalid_verify_contract_license_type() {
1168        let err = VerifyArgs::try_parse_from([
1169            "foundry-cli",
1170            "0x0000000000000000000000000000000000000000",
1171            "src/Domains.sol:Domains",
1172            "--license-type",
1173            "Unknown-License",
1174        ])
1175        .unwrap_err();
1176        assert!(err.to_string().contains("unsupported Etherscan license type"));
1177    }
1178
1179    #[test]
1180    fn can_parse_new_compiler_flags() {
1181        let args: VerifyArgs = VerifyArgs::parse_from([
1182            "foundry-cli",
1183            "0x0000000000000000000000000000000000000000",
1184            "src/Domains.sol:Domains",
1185            "--no-auto-detect",
1186            "--use",
1187            "0.8.23",
1188        ]);
1189        assert!(args.no_auto_detect);
1190        assert_eq!(args.use_solc.as_deref(), Some("0.8.23"));
1191    }
1192
1193    #[test]
1194    fn classify_verifier_probe_accepts_not_verified_response() {
1195        let body =
1196            r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"#;
1197        assert_eq!(
1198            classify_verifier_credential_response(StatusCode::OK, body),
1199            VerifierCredentialProbe::Accepted,
1200        );
1201    }
1202
1203    #[test]
1204    fn classify_verifier_probe_rejects_invalid_api_key() {
1205        let body = r#"{"status":"0","message":"NOTOK","result":"Invalid API Key"}"#;
1206        assert_eq!(
1207            classify_verifier_credential_response(StatusCode::OK, body),
1208            VerifierCredentialProbe::InvalidApiKey,
1209        );
1210        assert_eq!(
1211            classify_verifier_credential_response(StatusCode::UNAUTHORIZED, ""),
1212            VerifierCredentialProbe::InvalidApiKey,
1213        );
1214    }
1215
1216    #[test]
1217    fn classify_verifier_probe_treats_transient_errors_as_inconclusive() {
1218        let body = r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#;
1219        assert_eq!(
1220            classify_verifier_credential_response(StatusCode::OK, body),
1221            VerifierCredentialProbe::Inconclusive,
1222        );
1223        assert_eq!(
1224            classify_verifier_credential_response(
1225                StatusCode::OK,
1226                "Checking if the site connection is secure",
1227            ),
1228            VerifierCredentialProbe::Inconclusive,
1229        );
1230        assert_eq!(
1231            classify_verifier_credential_response(
1232                StatusCode::FORBIDDEN,
1233                "Sorry, you have been blocked",
1234            ),
1235            VerifierCredentialProbe::Inconclusive,
1236        );
1237        assert_eq!(
1238            classify_verifier_credential_response(StatusCode::FORBIDDEN, ""),
1239            VerifierCredentialProbe::Inconclusive,
1240        );
1241    }
1242
1243    #[test]
1244    fn parse_http_verifier_url_rejects_unsupported_schemes() {
1245        assert!(parse_http_verifier_url("https://example.com/api", "verifier").is_ok());
1246        assert!(parse_http_verifier_url("http://example.com/api", "verifier").is_ok());
1247
1248        let err = parse_http_verifier_url("gopher://example.com/api", "verifier").unwrap_err();
1249        assert!(
1250            err.to_string().contains("URL scheme must be http or https"),
1251            "unexpected error: {err:?}"
1252        );
1253    }
1254
1255    #[test]
1256    fn resolve_explicit_sourcify_overrides_api_key() {
1257        let args = VerifierArgs {
1258            verifier: Some(VerificationProviderType::Sourcify),
1259            verifier_api_key: None,
1260            verifier_url: None,
1261        };
1262        assert_eq!(
1263            args.resolve(Some("mykey"), Some(Chain::mainnet())),
1264            VerificationProviderType::Sourcify,
1265        );
1266    }
1267
1268    #[test]
1269    fn resolve_explicit_etherscan_is_etherscan() {
1270        let args = VerifierArgs {
1271            verifier: Some(VerificationProviderType::Etherscan),
1272            verifier_api_key: None,
1273            verifier_url: None,
1274        };
1275        assert_eq!(
1276            args.resolve(Some("mykey"), Some(Chain::mainnet())),
1277            VerificationProviderType::Etherscan,
1278        );
1279    }
1280
1281    #[test]
1282    fn resolve_implicit_with_key_and_known_chain_uses_etherscan() {
1283        let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
1284        assert_eq!(
1285            args.resolve(Some("mykey"), Some(Chain::mainnet())),
1286            VerificationProviderType::Etherscan,
1287        );
1288    }
1289
1290    #[test]
1291    fn resolve_implicit_with_key_and_unknown_chain_falls_back_to_sourcify() {
1292        let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
1293        assert_eq!(
1294            args.resolve(Some("mykey"), Some(Chain::from(3658348u64))),
1295            VerificationProviderType::Sourcify,
1296        );
1297    }
1298
1299    #[test]
1300    fn resolve_implicit_with_key_and_unknown_chain_but_url_uses_etherscan() {
1301        let args = VerifierArgs {
1302            verifier: None,
1303            verifier_api_key: None,
1304            verifier_url: Some("https://example.com/api".to_string()),
1305        };
1306        assert_eq!(
1307            args.resolve(Some("mykey"), Some(Chain::from(3658348u64))),
1308            VerificationProviderType::Etherscan,
1309        );
1310    }
1311
1312    #[test]
1313    fn resolve_implicit_no_key_falls_back_to_sourcify() {
1314        let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
1315        assert_eq!(args.resolve(None, Some(Chain::mainnet())), VerificationProviderType::Sourcify,);
1316    }
1317
1318    // Regression: custom-Sourcify chains (e.g. Tempo) register Sourcify-compatible URLs under
1319    // etherscan_urls(). An implicit ETHERSCAN_API_KEY must NOT route them to Etherscan.
1320    #[test]
1321    fn resolve_implicit_with_key_and_custom_sourcify_chain_falls_back_to_sourcify() {
1322        let tempo = Chain::from(4217u64); // NamedChain::Tempo
1323        assert!(tempo.is_custom_sourcify(), "sanity: Tempo should be is_custom_sourcify");
1324        let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
1325        assert_eq!(args.resolve(Some("mykey"), Some(tempo)), VerificationProviderType::Sourcify,);
1326    }
1327
1328    // Ensure the is_custom_sourcify() guard holds even when a URL is present (e.g. the URL was
1329    // auto-injected by run()). A user-supplied --verifier-url on a custom-Sourcify chain with a
1330    // key should still resolve to Sourcify, not Etherscan.
1331    #[test]
1332    fn resolve_custom_sourcify_chain_with_url_and_key_stays_sourcify() {
1333        let tempo = Chain::from(4217u64);
1334        let args = VerifierArgs {
1335            verifier: None,
1336            verifier_api_key: None,
1337            verifier_url: Some("https://contracts.tempo.xyz/".to_string()),
1338        };
1339        assert_eq!(args.resolve(Some("mykey"), Some(tempo)), VerificationProviderType::Sourcify,);
1340    }
1341
1342    #[test]
1343    fn collect_runs_adds_sourcify_provider() {
1344        let args: VerifyArgs = VerifyArgs::parse_from([
1345            "foundry-cli",
1346            "0x0000000000000000000000000000000000000000",
1347            "src/Counter.sol:Counter",
1348            "--etherscan-api-key",
1349            "k",
1350        ]);
1351        let resolved = args.verifier.resolve(Some("k"), Some(Chain::mainnet()));
1352        let runs = args.collect_runs(Chain::mainnet(), Some("k"), resolved, false).unwrap();
1353        assert_eq!(runs.len(), 2);
1354        assert_eq!(runs[0].label, VerificationProviderType::Etherscan);
1355        assert!(runs[0].required);
1356        assert_eq!(runs[1].label, VerificationProviderType::Sourcify);
1357        assert!(!runs[1].required);
1358        assert!(runs[1].args.verifier.verifier_api_key.is_none());
1359    }
1360
1361    #[test]
1362    fn collect_runs_skips_secondary_when_primary_is_sourcify() {
1363        let args: VerifyArgs = VerifyArgs::parse_from([
1364            "foundry-cli",
1365            "0x0000000000000000000000000000000000000000",
1366            "src/Counter.sol:Counter",
1367            "--verifier",
1368            "sourcify",
1369        ]);
1370        let resolved = args.verifier.resolve(None, Some(Chain::mainnet()));
1371        let runs = args.collect_runs(Chain::mainnet(), None, resolved, false).unwrap();
1372        assert_eq!(runs.len(), 1);
1373        assert_eq!(runs[0].label, VerificationProviderType::Sourcify);
1374        assert!(runs[0].required);
1375    }
1376
1377    #[test]
1378    fn collect_runs_skips_secondary_for_custom_verifier() {
1379        let args: VerifyArgs = VerifyArgs::parse_from([
1380            "foundry-cli",
1381            "0x0000000000000000000000000000000000000000",
1382            "src/Counter.sol:Counter",
1383            "--verifier",
1384            "custom",
1385            "--verifier-url",
1386            "https://internal.example.com/api",
1387        ]);
1388        let runs = args
1389            .collect_runs(Chain::mainnet(), None, VerificationProviderType::Custom, true)
1390            .unwrap();
1391        assert_eq!(runs.len(), 1);
1392        assert_eq!(runs[0].label, VerificationProviderType::Custom);
1393    }
1394
1395    #[test]
1396    fn collect_runs_skips_secondary_on_dev_chain() {
1397        let args: VerifyArgs = VerifyArgs::parse_from([
1398            "foundry-cli",
1399            "0x0000000000000000000000000000000000000000",
1400            "src/Counter.sol:Counter",
1401            "--etherscan-api-key",
1402            "k",
1403        ]);
1404        let anvil = Chain::from(31337u64);
1405        let resolved = args.verifier.resolve(Some("k"), Some(anvil));
1406        let runs = args.collect_runs(anvil, Some("k"), resolved, false).unwrap();
1407        assert_eq!(runs.len(), 1);
1408        assert!(runs[0].required);
1409    }
1410
1411    #[test]
1412    fn collect_runs_skips_secondary_with_user_verifier_url() {
1413        let args: VerifyArgs = VerifyArgs::parse_from([
1414            "foundry-cli",
1415            "0x0000000000000000000000000000000000000000",
1416            "src/Counter.sol:Counter",
1417            "--verifier",
1418            "blockscout",
1419            "--verifier-url",
1420            "https://internal-blockscout.example.com/api",
1421        ]);
1422        let resolved = args.verifier.resolve(None, Some(Chain::mainnet()));
1423        let runs = args.collect_runs(Chain::mainnet(), None, resolved, true).unwrap();
1424        assert_eq!(runs.len(), 1);
1425        assert_eq!(runs[0].label, VerificationProviderType::Blockscout);
1426    }
1427}