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, info::ContractInfo};
23use foundry_config::{
24    Chain, Config,
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, path, or executable name on `PATH` to build with.
418    ///
419    /// Valid values are in the format `x.y.z`, `solc:x.y.z`, `path/to/solc`, or `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 context = self.resolve_context().await?;
567        let config = context.config.clone();
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_with_dependencies()?;
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                solc.try_version()?
829            } else if let Some(entry) =
830                cache.as_ref().and_then(|cache| cache.files.get(&contract_path).cloned())
831            {
832                let unique_versions = entry
833                    .artifacts
834                    .get(&contract.name)
835                    .map(|artifacts| artifacts.keys().collect::<HashSet<_>>())
836                    .unwrap_or_default();
837
838                if unique_versions.is_empty() {
839                    eyre::bail!(
840                        "No matching artifact found for {}. This could be due to:\n\
841                        - Compiler version mismatch - the contract was compiled with a different Solidity version than what's being used for verification",
842                        contract.name
843                    );
844                } else if unique_versions.len() > 1 {
845                    warn!(
846                        "Ambiguous compiler versions found in cache: {}",
847                        unique_versions.iter().join(", ")
848                    );
849                    eyre::bail!(
850                        "Compiler version has to be set in `foundry.toml`. If the project was not deployed with foundry, specify the version through `--compiler-version` flag."
851                    );
852                }
853
854                unique_versions.into_iter().next().unwrap().to_owned()
855            } else {
856                eyre::bail!(
857                    "If cache is disabled, compiler version must be either provided with `--compiler-version` option or set in foundry.toml"
858                );
859            };
860
861            let settings = if let Some(profile) = &self.compilation_profile {
862                if profile == "default" {
863                    &project.settings
864                } else if let Some(settings) = project.additional_settings.get(profile.as_str()) {
865                    settings
866                } else {
867                    eyre::bail!("Unknown compilation profile: {}", profile);
868                }
869            } else if let Some((cache, entry)) = cache
870                .as_ref()
871                .and_then(|cache| Some((cache, cache.files.get(&contract_path)?.clone())))
872            {
873                let profiles = entry
874                    .artifacts
875                    .get(&contract.name)
876                    .and_then(|artifacts| {
877                        let mut cached_artifacts = artifacts.get(&version);
878                        // If we try to verify with specific build version and no cached artifacts
879                        // found, then check if we have artifacts cached for same version but
880                        // without any build metadata.
881                        // This could happen when artifacts are built / cached
882                        // with a version like `0.8.20` but verify is using a compiler-version arg
883                        // as `0.8.20+commit.a1b79de6`.
884                        // See <https://github.com/foundry-rs/foundry/issues/9510>.
885                        if cached_artifacts.is_none() && version.build != BuildMetadata::EMPTY {
886                            version.build = BuildMetadata::EMPTY;
887                            cached_artifacts = artifacts.get(&version);
888                        }
889                        cached_artifacts
890                    })
891                    .map(|artifacts| artifacts.keys().collect::<HashSet<_>>())
892                    .unwrap_or_default();
893
894                if profiles.is_empty() {
895                    eyre::bail!(
896                        "No matching artifact found for {} with compiler version {}. This could be due to:\n\
897                        - Compiler version mismatch - the contract was compiled with a different Solidity version",
898                        contract.name,
899                        version
900                    );
901                } else if profiles.len() > 1 {
902                    eyre::bail!(
903                        "Ambiguous compilation profiles found in cache: {}, please specify the profile through `--compilation-profile` flag",
904                        profiles.iter().join(", ")
905                    );
906                }
907
908                let profile = profiles.into_iter().next().unwrap().to_owned();
909                cache.profiles.get(&profile).expect("must be present")
910            } else if project.additional_settings.is_empty() {
911                &project.settings
912            } else {
913                eyre::bail!(
914                    "If cache is disabled, compilation profile must be provided with `--compilation-profile` option or set in foundry.toml"
915                );
916            };
917
918            VerificationContext::new(
919                contract_path,
920                contract.name.clone(),
921                version,
922                config,
923                settings.clone(),
924            )
925        } else {
926            if config.get_rpc_url().is_none() {
927                eyre::bail!("You have to provide a contract name or a valid RPC URL");
928            }
929            let provider = utils::get_provider(&config)?;
930            let code = provider.get_code_at(self.address).await?;
931
932            let output = ProjectCompiler::new().quiet(true).compile(&project)?;
933            let contracts = ContractsByArtifact::new(
934                output.artifact_ids().map(|(id, artifact)| (id, artifact.clone().into())),
935            );
936
937            let Some((artifact_id, _)) = contracts.find_by_deployed_code_exact(&code) else {
938                eyre::bail!(format!(
939                    "Bytecode at {} does not match any local contracts",
940                    self.address
941                ));
942            };
943
944            let settings = project
945                .settings_profiles()
946                .find_map(|(name, settings)| {
947                    (name == artifact_id.profile.as_str()).then_some(settings)
948                })
949                .expect("must be present");
950
951            VerificationContext::new(
952                artifact_id.source.clone(),
953                artifact_id.name.split('.').next().unwrap().to_owned(),
954                artifact_id.version.clone(),
955                config,
956                settings.clone(),
957            )
958        }
959    }
960
961    /// Detects the language for verification from source file extension, if none provided.
962    pub fn detect_language(&self, ctx: &VerificationContext) -> ContractLanguage {
963        self.language.unwrap_or_else(|| {
964            match ctx.target_path.extension().and_then(|e| e.to_str()) {
965                Some("vy") => ContractLanguage::Vyper,
966                _ => ContractLanguage::Solidity,
967            }
968        })
969    }
970}
971
972/// Check verification status arguments
973#[derive(Clone, Debug, Parser)]
974pub struct VerifyCheckArgs {
975    /// The verification ID.
976    ///
977    /// For Etherscan - Submission GUID.
978    ///
979    /// For Sourcify - Verification Job ID.
980    pub id: String,
981
982    #[command(flatten)]
983    pub retry: RetryArgs,
984
985    #[command(flatten)]
986    pub etherscan: EtherscanOpts,
987
988    #[command(flatten)]
989    pub verifier: VerifierArgs,
990}
991
992impl_figment_convert_cast!(VerifyCheckArgs);
993
994impl VerifyCheckArgs {
995    /// Run the verify command to submit the contract's source code for verification on etherscan
996    pub async fn run(self) -> Result<()> {
997        sh_status!("Checking verification status on {}", self.etherscan.chain.unwrap_or_default())?;
998        self.verifier
999            .effective_type()
1000            .client(
1001                self.etherscan.key().as_deref(),
1002                self.etherscan.chain,
1003                self.verifier.verifier_url.is_some(),
1004                self.verifier.is_explicitly_set(),
1005            )?
1006            .check(self)
1007            .await
1008    }
1009}
1010
1011impl FigmentProvider for VerifyCheckArgs {
1012    fn metadata(&self) -> Metadata {
1013        Metadata::named("Verify Check Provider")
1014    }
1015
1016    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
1017        let mut dict = self.etherscan.dict();
1018        if let Some(api_key) = &self.etherscan.key {
1019            dict.insert("etherscan_api_key".into(), api_key.as_str().into());
1020        }
1021
1022        Ok(Map::from([(Config::selected_profile(), dict)]))
1023    }
1024}
1025
1026/// Returns the Sourcify-compatible API URL for chains that have one registered in `etherscan_urls`.
1027///
1028/// Some chains register their Sourcify-compatible verification API under `etherscan_urls` in
1029/// alloy-chains. This function returns the properly formatted URL for such chains.
1030pub fn sourcify_api_url(chain: Chain) -> Option<String> {
1031    if chain.is_custom_sourcify() {
1032        chain.etherscan_urls().map(|(api_url, _)| {
1033            let api_url = api_url.trim_end_matches('/');
1034            format!("{api_url}/")
1035        })
1036    } else {
1037        None
1038    }
1039}
1040
1041/// Returns `true` for local/dev chains.
1042const fn is_dev_chain(chain: Chain) -> bool {
1043    use foundry_config::NamedChain;
1044    matches!(chain.named(), Some(NamedChain::Dev | NamedChain::AnvilHardhat | NamedChain::Cannon))
1045}
1046
1047#[cfg(test)]
1048mod tests {
1049    use super::*;
1050
1051    #[test]
1052    fn verifier_api_key_takes_precedence_over_etherscan_key() {
1053        let args = VerifierArgs { verifier_api_key: Some("explicit".into()), ..Default::default() };
1054        assert_eq!(args.resolve_api_key(Some("config")), Some("explicit"));
1055        assert_eq!(VerifierArgs::default().resolve_api_key(Some("config")), Some("config"));
1056    }
1057
1058    #[tokio::test]
1059    async fn external_context_rejects_control_characters_in_fqn() {
1060        let args =
1061            VerifyArgs::parse_from(["foundry-cli", "0xd8509bee9c9bf012282ad33aba0d87241baf5064"]);
1062        let context = ExternalVerificationContext {
1063            config: Config::default(),
1064            compiler_version: semver::Version::new(0, 8, 30),
1065            standard_json_input: std::sync::Arc::new(serde_json::json!({})),
1066            target: "A.sol:A\nforged".into(),
1067        };
1068        assert!(
1069            args.run_with_external_context(context)
1070                .await
1071                .unwrap_err()
1072                .to_string()
1073                .contains("printable")
1074        );
1075    }
1076
1077    #[test]
1078    fn external_run_context_clone_shares_standard_json() {
1079        let input = std::sync::Arc::new(serde_json::json!({"large": [1, 2, 3]}));
1080        let context = RunContext::External(Box::new(ExternalVerificationContext {
1081            config: Config::default(),
1082            compiler_version: semver::Version::new(0, 8, 30),
1083            standard_json_input: input.clone(),
1084            target: "A.sol:A".into(),
1085        }));
1086        let RunContext::External(context) = &context else { unreachable!() };
1087        let cloned = context.clone();
1088        assert!(std::sync::Arc::ptr_eq(&input, &cloned.standard_json_input));
1089    }
1090
1091    #[test]
1092    fn can_parse_verify_contract() {
1093        let args: VerifyArgs = VerifyArgs::parse_from([
1094            "foundry-cli",
1095            "0x0000000000000000000000000000000000000000",
1096            "src/Domains.sol:Domains",
1097            "--via-ir",
1098            "--license-type",
1099            "13",
1100        ]);
1101        assert!(args.via_ir);
1102        assert_eq!(args.license_type.as_deref(), Some("13"));
1103    }
1104
1105    #[test]
1106    fn can_parse_verify_contract_license_type_spdx() {
1107        let args: VerifyArgs = VerifyArgs::parse_from([
1108            "foundry-cli",
1109            "0x0000000000000000000000000000000000000000",
1110            "src/Domains.sol:Domains",
1111            "--license-type",
1112            "AGPL-3.0-or-later",
1113        ]);
1114        assert_eq!(args.license_type.as_deref(), Some("13"));
1115
1116        let args: VerifyArgs = VerifyArgs::parse_from([
1117            "foundry-cli",
1118            "0x0000000000000000000000000000000000000000",
1119            "src/Domains.sol:Domains",
1120            "--license-type",
1121            "MIT",
1122        ]);
1123        assert_eq!(args.license_type.as_deref(), Some("3"));
1124
1125        let args: VerifyArgs = VerifyArgs::parse_from([
1126            "foundry-cli",
1127            "0x0000000000000000000000000000000000000000",
1128            "src/Domains.sol:Domains",
1129            "--license-type",
1130            "apache 2.0",
1131        ]);
1132        assert_eq!(args.license_type.as_deref(), Some("12"));
1133    }
1134
1135    #[test]
1136    fn verify_contract_license_type_is_case_insensitive() {
1137        for variant in ["mit", "MIT", "Mit", "mIt"] {
1138            let args: VerifyArgs = VerifyArgs::parse_from([
1139                "foundry-cli",
1140                "0x0000000000000000000000000000000000000000",
1141                "src/Domains.sol:Domains",
1142                "--license-type",
1143                variant,
1144            ]);
1145            assert_eq!(args.license_type.as_deref(), Some("3"), "input: {variant}");
1146        }
1147    }
1148
1149    #[test]
1150    fn verify_contract_license_type_accepts_numeric_codes() {
1151        for (code, expected) in [("1", "1"), ("14", "14"), ("15", "15")] {
1152            let args: VerifyArgs = VerifyArgs::parse_from([
1153                "foundry-cli",
1154                "0x0000000000000000000000000000000000000000",
1155                "src/Domains.sol:Domains",
1156                "--license-type",
1157                code,
1158            ]);
1159            assert_eq!(args.license_type.as_deref(), Some(expected));
1160        }
1161    }
1162
1163    #[test]
1164    fn errors_on_invalid_verify_contract_license_type() {
1165        let err = VerifyArgs::try_parse_from([
1166            "foundry-cli",
1167            "0x0000000000000000000000000000000000000000",
1168            "src/Domains.sol:Domains",
1169            "--license-type",
1170            "Unknown-License",
1171        ])
1172        .unwrap_err();
1173        assert!(err.to_string().contains("unsupported Etherscan license type"));
1174    }
1175
1176    #[test]
1177    fn can_parse_new_compiler_flags() {
1178        let args: VerifyArgs = VerifyArgs::parse_from([
1179            "foundry-cli",
1180            "0x0000000000000000000000000000000000000000",
1181            "src/Domains.sol:Domains",
1182            "--no-auto-detect",
1183            "--use",
1184            "0.8.23",
1185        ]);
1186        assert!(args.no_auto_detect);
1187        assert_eq!(args.use_solc.as_deref(), Some("0.8.23"));
1188    }
1189
1190    #[test]
1191    fn classify_verifier_probe_accepts_not_verified_response() {
1192        let body =
1193            r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"#;
1194        assert_eq!(
1195            classify_verifier_credential_response(StatusCode::OK, body),
1196            VerifierCredentialProbe::Accepted,
1197        );
1198    }
1199
1200    #[test]
1201    fn classify_verifier_probe_rejects_invalid_api_key() {
1202        let body = r#"{"status":"0","message":"NOTOK","result":"Invalid API Key"}"#;
1203        assert_eq!(
1204            classify_verifier_credential_response(StatusCode::OK, body),
1205            VerifierCredentialProbe::InvalidApiKey,
1206        );
1207        assert_eq!(
1208            classify_verifier_credential_response(StatusCode::UNAUTHORIZED, ""),
1209            VerifierCredentialProbe::InvalidApiKey,
1210        );
1211    }
1212
1213    #[test]
1214    fn classify_verifier_probe_treats_transient_errors_as_inconclusive() {
1215        let body = r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#;
1216        assert_eq!(
1217            classify_verifier_credential_response(StatusCode::OK, body),
1218            VerifierCredentialProbe::Inconclusive,
1219        );
1220        assert_eq!(
1221            classify_verifier_credential_response(
1222                StatusCode::OK,
1223                "Checking if the site connection is secure",
1224            ),
1225            VerifierCredentialProbe::Inconclusive,
1226        );
1227        assert_eq!(
1228            classify_verifier_credential_response(
1229                StatusCode::FORBIDDEN,
1230                "Sorry, you have been blocked",
1231            ),
1232            VerifierCredentialProbe::Inconclusive,
1233        );
1234        assert_eq!(
1235            classify_verifier_credential_response(StatusCode::FORBIDDEN, ""),
1236            VerifierCredentialProbe::Inconclusive,
1237        );
1238    }
1239
1240    #[test]
1241    fn parse_http_verifier_url_rejects_unsupported_schemes() {
1242        assert!(parse_http_verifier_url("https://example.com/api", "verifier").is_ok());
1243        assert!(parse_http_verifier_url("http://example.com/api", "verifier").is_ok());
1244
1245        let err = parse_http_verifier_url("gopher://example.com/api", "verifier").unwrap_err();
1246        assert!(
1247            err.to_string().contains("URL scheme must be http or https"),
1248            "unexpected error: {err:?}"
1249        );
1250    }
1251
1252    #[test]
1253    fn resolve_explicit_sourcify_overrides_api_key() {
1254        let args = VerifierArgs {
1255            verifier: Some(VerificationProviderType::Sourcify),
1256            verifier_api_key: None,
1257            verifier_url: None,
1258        };
1259        assert_eq!(
1260            args.resolve(Some("mykey"), Some(Chain::mainnet())),
1261            VerificationProviderType::Sourcify,
1262        );
1263    }
1264
1265    #[test]
1266    fn resolve_explicit_etherscan_is_etherscan() {
1267        let args = VerifierArgs {
1268            verifier: Some(VerificationProviderType::Etherscan),
1269            verifier_api_key: None,
1270            verifier_url: None,
1271        };
1272        assert_eq!(
1273            args.resolve(Some("mykey"), Some(Chain::mainnet())),
1274            VerificationProviderType::Etherscan,
1275        );
1276    }
1277
1278    #[test]
1279    fn resolve_implicit_with_key_and_known_chain_uses_etherscan() {
1280        let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
1281        assert_eq!(
1282            args.resolve(Some("mykey"), Some(Chain::mainnet())),
1283            VerificationProviderType::Etherscan,
1284        );
1285    }
1286
1287    #[test]
1288    fn resolve_implicit_with_key_and_unknown_chain_falls_back_to_sourcify() {
1289        let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
1290        assert_eq!(
1291            args.resolve(Some("mykey"), Some(Chain::from(3658348u64))),
1292            VerificationProviderType::Sourcify,
1293        );
1294    }
1295
1296    #[test]
1297    fn resolve_implicit_with_key_and_unknown_chain_but_url_uses_etherscan() {
1298        let args = VerifierArgs {
1299            verifier: None,
1300            verifier_api_key: None,
1301            verifier_url: Some("https://example.com/api".to_string()),
1302        };
1303        assert_eq!(
1304            args.resolve(Some("mykey"), Some(Chain::from(3658348u64))),
1305            VerificationProviderType::Etherscan,
1306        );
1307    }
1308
1309    #[test]
1310    fn resolve_implicit_no_key_falls_back_to_sourcify() {
1311        let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
1312        assert_eq!(args.resolve(None, Some(Chain::mainnet())), VerificationProviderType::Sourcify,);
1313    }
1314
1315    // Regression: custom-Sourcify chains (e.g. Tempo) register Sourcify-compatible URLs under
1316    // etherscan_urls(). An implicit ETHERSCAN_API_KEY must NOT route them to Etherscan.
1317    #[test]
1318    fn resolve_implicit_with_key_and_custom_sourcify_chain_falls_back_to_sourcify() {
1319        let tempo = Chain::from(4217u64); // NamedChain::Tempo
1320        assert!(tempo.is_custom_sourcify(), "sanity: Tempo should be is_custom_sourcify");
1321        let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
1322        assert_eq!(args.resolve(Some("mykey"), Some(tempo)), VerificationProviderType::Sourcify,);
1323    }
1324
1325    // Ensure the is_custom_sourcify() guard holds even when a URL is present (e.g. the URL was
1326    // auto-injected by run()). A user-supplied --verifier-url on a custom-Sourcify chain with a
1327    // key should still resolve to Sourcify, not Etherscan.
1328    #[test]
1329    fn resolve_custom_sourcify_chain_with_url_and_key_stays_sourcify() {
1330        let tempo = Chain::from(4217u64);
1331        let args = VerifierArgs {
1332            verifier: None,
1333            verifier_api_key: None,
1334            verifier_url: Some("https://contracts.tempo.xyz/".to_string()),
1335        };
1336        assert_eq!(args.resolve(Some("mykey"), Some(tempo)), VerificationProviderType::Sourcify,);
1337    }
1338
1339    #[test]
1340    fn collect_runs_adds_sourcify_provider() {
1341        let args: VerifyArgs = VerifyArgs::parse_from([
1342            "foundry-cli",
1343            "0x0000000000000000000000000000000000000000",
1344            "src/Counter.sol:Counter",
1345            "--etherscan-api-key",
1346            "k",
1347        ]);
1348        let resolved = args.verifier.resolve(Some("k"), Some(Chain::mainnet()));
1349        let runs = args.collect_runs(Chain::mainnet(), Some("k"), resolved, false).unwrap();
1350        assert_eq!(runs.len(), 2);
1351        assert_eq!(runs[0].label, VerificationProviderType::Etherscan);
1352        assert!(runs[0].required);
1353        assert_eq!(runs[1].label, VerificationProviderType::Sourcify);
1354        assert!(!runs[1].required);
1355        assert!(runs[1].args.verifier.verifier_api_key.is_none());
1356    }
1357
1358    #[test]
1359    fn collect_runs_skips_secondary_when_primary_is_sourcify() {
1360        let args: VerifyArgs = VerifyArgs::parse_from([
1361            "foundry-cli",
1362            "0x0000000000000000000000000000000000000000",
1363            "src/Counter.sol:Counter",
1364            "--verifier",
1365            "sourcify",
1366        ]);
1367        let resolved = args.verifier.resolve(None, Some(Chain::mainnet()));
1368        let runs = args.collect_runs(Chain::mainnet(), None, resolved, false).unwrap();
1369        assert_eq!(runs.len(), 1);
1370        assert_eq!(runs[0].label, VerificationProviderType::Sourcify);
1371        assert!(runs[0].required);
1372    }
1373
1374    #[test]
1375    fn collect_runs_skips_secondary_for_custom_verifier() {
1376        let args: VerifyArgs = VerifyArgs::parse_from([
1377            "foundry-cli",
1378            "0x0000000000000000000000000000000000000000",
1379            "src/Counter.sol:Counter",
1380            "--verifier",
1381            "custom",
1382            "--verifier-url",
1383            "https://internal.example.com/api",
1384        ]);
1385        let runs = args
1386            .collect_runs(Chain::mainnet(), None, VerificationProviderType::Custom, true)
1387            .unwrap();
1388        assert_eq!(runs.len(), 1);
1389        assert_eq!(runs[0].label, VerificationProviderType::Custom);
1390    }
1391
1392    #[test]
1393    fn collect_runs_skips_secondary_on_dev_chain() {
1394        let args: VerifyArgs = VerifyArgs::parse_from([
1395            "foundry-cli",
1396            "0x0000000000000000000000000000000000000000",
1397            "src/Counter.sol:Counter",
1398            "--etherscan-api-key",
1399            "k",
1400        ]);
1401        let anvil = Chain::from(31337u64);
1402        let resolved = args.verifier.resolve(Some("k"), Some(anvil));
1403        let runs = args.collect_runs(anvil, Some("k"), resolved, false).unwrap();
1404        assert_eq!(runs.len(), 1);
1405        assert!(runs[0].required);
1406    }
1407
1408    #[test]
1409    fn collect_runs_skips_secondary_with_user_verifier_url() {
1410        let args: VerifyArgs = VerifyArgs::parse_from([
1411            "foundry-cli",
1412            "0x0000000000000000000000000000000000000000",
1413            "src/Counter.sol:Counter",
1414            "--verifier",
1415            "blockscout",
1416            "--verifier-url",
1417            "https://internal-blockscout.example.com/api",
1418        ]);
1419        let resolved = args.verifier.resolve(None, Some(Chain::mainnet()));
1420        let runs = args.collect_runs(Chain::mainnet(), None, resolved, true).unwrap();
1421        assert_eq!(runs.len(), 1);
1422        assert_eq!(runs[0].label, VerificationProviderType::Blockscout);
1423    }
1424}