Skip to main content

forge_verify/
verify.rs

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