1use crate::{
4 RetryArgs,
5 etherscan::EtherscanVerificationProvider,
6 provider::{VerificationContext, VerificationProvider, VerificationProviderType},
7 utils::wrap_verifier_url_error,
8};
9use alloy_primitives::{Address, TxHash, map::HashSet};
10use alloy_provider::Provider;
11use clap::{Parser, ValueEnum, ValueHint};
12use eyre::{Context, Result};
13use foundry_cli::{
14 opts::{EtherscanOpts, RpcOpts},
15 utils::{self, LoadConfig},
16};
17use foundry_common::{ContractsByArtifact, compile::ProjectCompiler};
18use foundry_compilers::{artifacts::EvmVersion, compilers::solc::Solc, info::ContractInfo};
19use foundry_config::{
20 Chain, Config, SolcReq,
21 figment::{
22 Error, Metadata, Profile, Provider as FigmentProvider,
23 value::{Dict, Map, Value},
24 },
25 impl_figment_convert, impl_figment_convert_cast,
26};
27use itertools::Itertools;
28use reqwest::{Client, StatusCode, Url};
29use semver::BuildMetadata;
30use serde::Deserialize;
31use std::{path::PathBuf, time::Duration};
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34enum VerifierCredentialProbe {
35 Accepted,
36 InvalidApiKey,
37 Inconclusive,
38}
39
40#[derive(Debug, Deserialize)]
41struct EtherscanProbeResponse {
42 status: String,
43 result: Option<serde_json::Value>,
44}
45
46fn verifier_credential_probe_query(api_key: Option<&str>) -> Vec<(&'static str, String)> {
47 let mut query = vec![
48 ("module", "contract".to_string()),
49 ("action", "getabi".to_string()),
50 ("address", Address::ZERO.to_string()),
51 ];
52 if let Some(api_key) = api_key {
53 query.push(("apikey", api_key.to_string()));
54 }
55 query
56}
57
58fn classify_verifier_credential_response(
59 status: StatusCode,
60 body: &str,
61) -> VerifierCredentialProbe {
62 let lower = body.to_lowercase();
63 if lower.contains("invalid api key") || lower.contains("invalid_api_key") {
64 return VerifierCredentialProbe::InvalidApiKey;
65 }
66
67 if lower.contains("contract source code not verified")
68 || lower.contains("contract not found")
69 || lower.contains("contract was not found")
70 {
71 return VerifierCredentialProbe::Accepted;
72 }
73
74 if status == StatusCode::UNAUTHORIZED {
75 return VerifierCredentialProbe::InvalidApiKey;
76 }
77
78 if !status.is_success()
79 || lower.contains("max rate limit reached")
80 || lower.contains("sorry, you have been blocked")
81 || lower.contains("checking if the site connection is secure")
82 {
83 return VerifierCredentialProbe::Inconclusive;
84 }
85
86 match serde_json::from_str::<EtherscanProbeResponse>(body) {
87 Ok(resp) if resp.status == "1" => VerifierCredentialProbe::Accepted,
88 Ok(resp) => resp
89 .result
90 .and_then(|result| result.as_str().map(str::to_lowercase))
91 .map(|result| {
92 if result.contains("invalid api key") || result.contains("invalid_api_key") {
93 VerifierCredentialProbe::InvalidApiKey
94 } else if result.contains("max rate limit reached") {
95 VerifierCredentialProbe::Inconclusive
96 } else if result.contains("contract source code not verified")
97 || result.contains("contract not found")
98 || result.contains("contract was not found")
99 {
100 VerifierCredentialProbe::Accepted
101 } else {
102 VerifierCredentialProbe::Inconclusive
103 }
104 })
105 .unwrap_or(VerifierCredentialProbe::Inconclusive),
106 Err(_) => VerifierCredentialProbe::Inconclusive,
107 }
108}
109
110fn parse_http_verifier_url(url: &str, label: &str) -> Result<Url> {
111 let url = Url::parse(url).wrap_err_with(|| format!("invalid {label} URL `{url}`"))?;
112 if !matches!(url.scheme(), "http" | "https") {
113 eyre::bail!("invalid {label} URL `{url}`: URL scheme must be http or https");
114 }
115 Ok(url)
116}
117
118async fn probe_verifier_credentials(
119 url: Url,
120 api_key: Option<&str>,
121) -> Result<VerifierCredentialProbe, reqwest::Error> {
122 let resp = Client::new()
123 .get(url)
124 .query(&verifier_credential_probe_query(api_key))
125 .timeout(Duration::from_secs(10))
126 .send()
127 .await?;
128 let status = resp.status();
129 let body = resp.text().await?;
130 Ok(classify_verifier_credential_response(status, &body))
131}
132
133#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
137pub enum ContractLanguage {
138 Solidity,
140 Vyper,
142}
143
144#[derive(Clone, Debug, Default, Parser)]
146pub struct VerifierArgs {
147 #[arg(long, help_heading = "Verifier options", value_enum)]
149 pub verifier: Option<VerificationProviderType>,
150
151 #[arg(long, help_heading = "Verifier options", env = "VERIFIER_API_KEY")]
153 pub verifier_api_key: Option<String>,
154
155 #[arg(long, help_heading = "Verifier options", env = "VERIFIER_URL")]
157 pub verifier_url: Option<String>,
158}
159
160impl VerifierArgs {
161 pub fn effective_type(&self) -> VerificationProviderType {
166 self.verifier.unwrap_or_default()
167 }
168
169 pub const fn is_explicitly_set(&self) -> bool {
171 self.verifier.is_some()
172 }
173
174 pub fn resolve_api_key<'a>(&'a self, etherscan_key: Option<&'a str>) -> Option<&'a str> {
177 self.verifier_api_key.as_deref().or(etherscan_key)
178 }
179
180 pub async fn check_credentials(
185 &self,
186 api_key: Option<&str>,
187 chain: Chain,
188 config: &Config,
189 ) -> eyre::Result<()> {
190 let resolved = self.resolve(api_key, Some(chain));
191 match resolved {
192 VerificationProviderType::Etherscan
193 | VerificationProviderType::Blockscout
194 | VerificationProviderType::Oklink => {
195 let etherscan_opts =
196 EtherscanOpts { key: api_key.map(str::to_owned), chain: Some(chain) };
197 let client = EtherscanVerificationProvider::default().client(
198 ðerscan_opts,
199 self,
200 config,
201 )?;
202 match tokio::time::timeout(
203 Duration::from_secs(10),
204 probe_verifier_credentials(
205 client.etherscan_api_url().clone(),
206 client.api_key(),
207 ),
208 )
209 .await
210 {
211 Err(_) => {
212 sh_warn!("verifier credential check timed out, proceeding anyway")?;
213 }
214 Ok(Ok(VerifierCredentialProbe::Accepted)) => {}
215 Ok(Ok(VerifierCredentialProbe::InvalidApiKey)) => {
216 eyre::bail!("verifier credential check failed: invalid API key");
217 }
218 Ok(Ok(VerifierCredentialProbe::Inconclusive) | Err(_)) => {
219 sh_warn!("verifier credential check inconclusive, proceeding anyway")?;
220 }
221 }
222 }
223 VerificationProviderType::Custom => {
224 if let Some(url) = &self.verifier_url {
227 let url = parse_http_verifier_url(url, "verifier")?;
228 match probe_verifier_credentials(url, api_key).await {
229 Err(_) => {
230 sh_warn!("verifier credential check failed, proceeding anyway")?;
231 }
232 Ok(
233 VerifierCredentialProbe::Accepted
234 | VerifierCredentialProbe::Inconclusive,
235 ) => {}
236 Ok(VerifierCredentialProbe::InvalidApiKey) => {
237 eyre::bail!("verifier credential check failed: invalid API key")
238 }
239 }
240 }
241 }
242 VerificationProviderType::Sourcify => {
243 if let Some(url) = &self.verifier_url {
245 let url = parse_http_verifier_url(url, "Sourcify")?;
246 match Client::new()
247 .get(url.clone())
248 .timeout(Duration::from_secs(10))
249 .send()
250 .await
251 {
252 Err(_) => {
253 sh_warn!(
254 "Sourcify URL `{url}` could not be reached, proceeding anyway"
255 )?;
256 }
257 Ok(resp) => {
258 let status = resp.status();
259 if !status.is_success() && status != StatusCode::NOT_FOUND {
260 sh_warn!(
261 "Sourcify URL `{url}` returned HTTP {status}, proceeding anyway"
262 )?;
263 }
264 }
265 }
266 }
267 }
268 }
269 Ok(())
270 }
271
272 pub fn resolve(
282 &self,
283 etherscan_key: Option<&str>,
284 chain: Option<Chain>,
285 ) -> VerificationProviderType {
286 if let Some(v) = self.verifier {
287 return v;
288 }
289 let has_key = etherscan_key.is_some_and(|k| !k.is_empty());
290 if has_key && !chain.is_some_and(|c| c.is_custom_sourcify()) {
294 let chain_has_etherscan_url = chain.is_none_or(|c| c.etherscan_urls().is_some());
295 if chain_has_etherscan_url || self.verifier_url.is_some() {
296 return VerificationProviderType::Etherscan;
297 }
298 }
299 VerificationProviderType::Sourcify
300 }
301}
302
303#[derive(Clone, Debug, Parser)]
305pub struct VerifyArgs {
306 pub address: Address,
308
309 pub contract: Option<ContractInfo>,
311
312 #[arg(
314 long,
315 conflicts_with = "constructor_args_path",
316 value_name = "ARGS",
317 visible_alias = "encoded-constructor-args"
318 )]
319 pub constructor_args: Option<String>,
320
321 #[arg(long, value_hint = ValueHint::FilePath, value_name = "PATH")]
323 pub constructor_args_path: Option<PathBuf>,
324
325 #[arg(long)]
327 pub guess_constructor_args: bool,
328
329 #[arg(long)]
331 pub creation_transaction_hash: Option<TxHash>,
332
333 #[arg(long, value_name = "VERSION")]
335 pub compiler_version: Option<String>,
336
337 #[arg(long, value_name = "PROFILE_NAME")]
339 pub compilation_profile: Option<String>,
340
341 #[arg(long, visible_alias = "optimizer-runs", value_name = "NUM")]
343 pub num_of_optimizations: Option<usize>,
344
345 #[arg(long)]
347 pub flatten: bool,
348
349 #[arg(short, long)]
351 pub force: bool,
352
353 #[arg(long)]
355 pub skip_is_verified_check: bool,
356
357 #[arg(long)]
359 pub watch: bool,
360
361 #[arg(long, help_heading = "Linker options", env = "DAPP_LIBRARIES")]
363 pub libraries: Vec<String>,
364
365 #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
370 pub root: Option<PathBuf>,
371
372 #[arg(long, conflicts_with = "flatten")]
377 pub show_standard_json_input: bool,
378
379 #[arg(long)]
381 pub via_ir: bool,
382
383 #[arg(long, value_name = "CODE", help_heading = "Verifier options")]
388 pub license_type: Option<String>,
389
390 #[arg(long)]
394 pub evm_version: Option<EvmVersion>,
395
396 #[arg(long, help_heading = "Compiler options")]
398 pub no_auto_detect: bool,
399
400 #[arg(long = "use", help_heading = "Compiler options", value_name = "SOLC_VERSION")]
404 pub use_solc: Option<String>,
405
406 #[command(flatten)]
407 pub etherscan: EtherscanOpts,
408
409 #[command(flatten)]
410 pub rpc: RpcOpts,
411
412 #[command(flatten)]
413 pub retry: RetryArgs,
414
415 #[command(flatten)]
416 pub verifier: VerifierArgs,
417
418 #[arg(long, value_enum)]
422 pub language: Option<ContractLanguage>,
423}
424
425impl_figment_convert!(VerifyArgs);
426
427impl FigmentProvider for VerifyArgs {
428 fn metadata(&self) -> Metadata {
429 Metadata::named("Verify Provider")
430 }
431
432 fn data(&self) -> Result<Map<Profile, Dict>, Error> {
433 let mut dict = self.etherscan.dict();
434 dict.extend(self.rpc.dict());
435
436 if let Some(root) = self.root.as_ref() {
437 dict.insert("root".to_string(), Value::serialize(root)?);
438 }
439 if let Some(optimizer_runs) = self.num_of_optimizations {
440 dict.insert("optimizer".to_string(), Value::serialize(true)?);
441 dict.insert("optimizer_runs".to_string(), Value::serialize(optimizer_runs)?);
442 }
443 if let Some(evm_version) = self.evm_version {
444 dict.insert("evm_version".to_string(), Value::serialize(evm_version)?);
445 }
446 if self.via_ir {
447 dict.insert("via_ir".to_string(), Value::serialize(self.via_ir)?);
448 }
449
450 if self.no_auto_detect {
451 dict.insert("auto_detect_solc".to_string(), Value::serialize(false)?);
452 }
453
454 if let Some(ref solc) = self.use_solc {
455 let solc = solc.trim_start_matches("solc:");
456 dict.insert("solc".to_string(), Value::serialize(solc)?);
457 }
458
459 if let Some(api_key) = &self.verifier.verifier_api_key {
460 dict.insert("etherscan_api_key".into(), api_key.as_str().into());
461 }
462
463 Ok(Map::from([(Config::selected_profile(), dict)]))
464 }
465}
466
467impl VerifyArgs {
468 pub async fn run(mut self) -> Result<()> {
470 let config = self.load_config()?;
471
472 if self.guess_constructor_args && config.get_rpc_url().is_none() {
473 eyre::bail!(
474 "You have to provide a valid RPC URL to use --guess-constructor-args feature"
475 )
476 }
477
478 let chain = match config.get_rpc_url() {
481 Some(_) => {
482 let provider = utils::get_provider(&config)?;
483 utils::get_chain(config.chain, provider).await?
484 }
485 None => config.chain.unwrap_or_default(),
486 };
487
488 let context = self.resolve_context().await?;
489
490 self.etherscan.chain = Some(chain);
492 self.etherscan.key = config
497 .get_etherscan_config_with_chain(Some(chain))?
498 .map(|c| c.key)
499 .or_else(|| config.etherscan_api_key.clone());
500
501 let had_user_verifier_url = self.verifier.verifier_url.is_some();
505
506 let etherscan_key = self.etherscan.key();
510 let resolved = self.verifier.resolve(etherscan_key.as_deref(), self.etherscan.chain);
511
512 if resolved.is_sourcify()
515 && !had_user_verifier_url
516 && let Some(url) = sourcify_api_url(chain)
517 {
518 self.verifier.verifier_url = Some(url);
519 }
520
521 if self.show_standard_json_input {
522 let args = EtherscanVerificationProvider::default()
523 .create_verify_request(&self, &context)
524 .await?;
525 sh_println!("{}", args.source)?;
526 return Ok(());
527 }
528
529 let verifier_url = self.verifier.verifier_url.clone();
530 sh_status!("Start verifying contract `{}` deployed on {chain}", self.address)?;
531 if let Some(version) = &self.evm_version {
532 sh_status!("EVM version: {version}")?;
533 }
534 if let Some(version) = &self.compiler_version {
535 sh_status!("Compiler version: {version}")?;
536 }
537 if let Some(optimizations) = &self.num_of_optimizations {
538 sh_status!("Optimizations: {optimizations}")?
539 }
540 if let Some(args) = &self.constructor_args
541 && !args.is_empty()
542 {
543 sh_status!("Constructor args: {args}")?
544 }
545 let using_etherscan = resolved.is_etherscan();
546 resolved
547 .client(
548 etherscan_key.as_deref(),
549 self.etherscan.chain,
550 had_user_verifier_url,
551 self.verifier.is_explicitly_set(),
552 )?
553 .verify(self, context)
554 .await
555 .map_err(|err| wrap_verifier_url_error(err, verifier_url.as_deref(), using_etherscan))
556 }
557
558 pub fn verification_provider(&self) -> Result<Box<dyn VerificationProvider>> {
560 self.verifier.effective_type().client(
561 self.etherscan.key().as_deref(),
562 self.etherscan.chain,
563 self.verifier.verifier_url.is_some(),
564 self.verifier.is_explicitly_set(),
565 )
566 }
567
568 pub async fn resolve_context(&self) -> Result<VerificationContext> {
571 let mut config = self.load_config()?;
572 config.libraries.extend(self.libraries.clone());
573
574 let project = config.project()?;
575
576 if let Some(ref contract) = self.contract {
577 let contract_path = if let Some(ref path) = contract.path {
578 project.root().join(PathBuf::from(path))
579 } else {
580 project.find_contract_path(&contract.name)?
581 };
582
583 let cache = project.read_cache_file().ok();
584
585 let mut version = if let Some(ref version) = self.compiler_version {
586 version.trim_start_matches('v').parse()?
587 } else if let Some(ref solc) = config.solc {
588 match solc {
589 SolcReq::Version(version) => version.to_owned(),
590 SolcReq::Local(solc) => Solc::new(solc)?.version,
591 }
592 } else if let Some(entry) =
593 cache.as_ref().and_then(|cache| cache.files.get(&contract_path).cloned())
594 {
595 let unique_versions = entry
596 .artifacts
597 .get(&contract.name)
598 .map(|artifacts| artifacts.keys().collect::<HashSet<_>>())
599 .unwrap_or_default();
600
601 if unique_versions.is_empty() {
602 eyre::bail!(
603 "No matching artifact found for {}. This could be due to:\n\
604 - Compiler version mismatch - the contract was compiled with a different Solidity version than what's being used for verification",
605 contract.name
606 );
607 } else if unique_versions.len() > 1 {
608 warn!(
609 "Ambiguous compiler versions found in cache: {}",
610 unique_versions.iter().join(", ")
611 );
612 eyre::bail!(
613 "Compiler version has to be set in `foundry.toml`. If the project was not deployed with foundry, specify the version through `--compiler-version` flag."
614 )
615 }
616
617 unique_versions.into_iter().next().unwrap().to_owned()
618 } else {
619 eyre::bail!(
620 "If cache is disabled, compiler version must be either provided with `--compiler-version` option or set in foundry.toml"
621 )
622 };
623
624 let settings = if let Some(profile) = &self.compilation_profile {
625 if profile == "default" {
626 &project.settings
627 } else if let Some(settings) = project.additional_settings.get(profile.as_str()) {
628 settings
629 } else {
630 eyre::bail!("Unknown compilation profile: {}", profile)
631 }
632 } else if let Some((cache, entry)) = cache
633 .as_ref()
634 .and_then(|cache| Some((cache, cache.files.get(&contract_path)?.clone())))
635 {
636 let profiles = entry
637 .artifacts
638 .get(&contract.name)
639 .and_then(|artifacts| {
640 let mut cached_artifacts = artifacts.get(&version);
641 if cached_artifacts.is_none() && version.build != BuildMetadata::EMPTY {
649 version.build = BuildMetadata::EMPTY;
650 cached_artifacts = artifacts.get(&version);
651 }
652 cached_artifacts
653 })
654 .map(|artifacts| artifacts.keys().collect::<HashSet<_>>())
655 .unwrap_or_default();
656
657 if profiles.is_empty() {
658 eyre::bail!(
659 "No matching artifact found for {} with compiler version {}. This could be due to:\n\
660 - Compiler version mismatch - the contract was compiled with a different Solidity version",
661 contract.name,
662 version
663 );
664 } else if profiles.len() > 1 {
665 eyre::bail!(
666 "Ambiguous compilation profiles found in cache: {}, please specify the profile through `--compilation-profile` flag",
667 profiles.iter().join(", ")
668 )
669 }
670
671 let profile = profiles.into_iter().next().unwrap().to_owned();
672 cache.profiles.get(&profile).expect("must be present")
673 } else if project.additional_settings.is_empty() {
674 &project.settings
675 } else {
676 eyre::bail!(
677 "If cache is disabled, compilation profile must be provided with `--compilation-profile` option or set in foundry.toml"
678 )
679 };
680
681 VerificationContext::new(
682 contract_path,
683 contract.name.clone(),
684 version,
685 config,
686 settings.clone(),
687 )
688 } else {
689 if config.get_rpc_url().is_none() {
690 eyre::bail!("You have to provide a contract name or a valid RPC URL")
691 }
692 let provider = utils::get_provider(&config)?;
693 let code = provider.get_code_at(self.address).await?;
694
695 let output = ProjectCompiler::new().quiet(true).compile(&project)?;
696 let contracts = ContractsByArtifact::new(
697 output.artifact_ids().map(|(id, artifact)| (id, artifact.clone().into())),
698 );
699
700 let Some((artifact_id, _)) = contracts.find_by_deployed_code_exact(&code) else {
701 eyre::bail!(format!(
702 "Bytecode at {} does not match any local contracts",
703 self.address
704 ))
705 };
706
707 let settings = project
708 .settings_profiles()
709 .find_map(|(name, settings)| {
710 (name == artifact_id.profile.as_str()).then_some(settings)
711 })
712 .expect("must be present");
713
714 VerificationContext::new(
715 artifact_id.source.clone(),
716 artifact_id.name.split('.').next().unwrap().to_owned(),
717 artifact_id.version.clone(),
718 config,
719 settings.clone(),
720 )
721 }
722 }
723
724 pub fn detect_language(&self, ctx: &VerificationContext) -> ContractLanguage {
726 self.language.unwrap_or_else(|| {
727 match ctx.target_path.extension().and_then(|e| e.to_str()) {
728 Some("vy") => ContractLanguage::Vyper,
729 _ => ContractLanguage::Solidity,
730 }
731 })
732 }
733}
734
735#[derive(Clone, Debug, Parser)]
737pub struct VerifyCheckArgs {
738 pub id: String,
744
745 #[command(flatten)]
746 pub retry: RetryArgs,
747
748 #[command(flatten)]
749 pub etherscan: EtherscanOpts,
750
751 #[command(flatten)]
752 pub verifier: VerifierArgs,
753}
754
755impl_figment_convert_cast!(VerifyCheckArgs);
756
757impl VerifyCheckArgs {
758 pub async fn run(self) -> Result<()> {
760 sh_status!("Checking verification status on {}", self.etherscan.chain.unwrap_or_default())?;
761 self.verifier
762 .effective_type()
763 .client(
764 self.etherscan.key().as_deref(),
765 self.etherscan.chain,
766 self.verifier.verifier_url.is_some(),
767 self.verifier.is_explicitly_set(),
768 )?
769 .check(self)
770 .await
771 }
772}
773
774impl FigmentProvider for VerifyCheckArgs {
775 fn metadata(&self) -> Metadata {
776 Metadata::named("Verify Check Provider")
777 }
778
779 fn data(&self) -> Result<Map<Profile, Dict>, Error> {
780 let mut dict = self.etherscan.dict();
781 if let Some(api_key) = &self.etherscan.key {
782 dict.insert("etherscan_api_key".into(), api_key.as_str().into());
783 }
784
785 Ok(Map::from([(Config::selected_profile(), dict)]))
786 }
787}
788
789fn sourcify_api_url(chain: Chain) -> Option<String> {
794 if chain.is_custom_sourcify() {
795 chain.etherscan_urls().map(|(api_url, _)| {
796 let api_url = api_url.trim_end_matches('/');
797 format!("{api_url}/")
798 })
799 } else {
800 None
801 }
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 #[test]
809 fn can_parse_verify_contract() {
810 let args: VerifyArgs = VerifyArgs::parse_from([
811 "foundry-cli",
812 "0x0000000000000000000000000000000000000000",
813 "src/Domains.sol:Domains",
814 "--via-ir",
815 "--license-type",
816 "13",
817 ]);
818 assert!(args.via_ir);
819 assert_eq!(args.license_type.as_deref(), Some("13"));
820 }
821
822 #[test]
823 fn can_parse_new_compiler_flags() {
824 let args: VerifyArgs = VerifyArgs::parse_from([
825 "foundry-cli",
826 "0x0000000000000000000000000000000000000000",
827 "src/Domains.sol:Domains",
828 "--no-auto-detect",
829 "--use",
830 "0.8.23",
831 ]);
832 assert!(args.no_auto_detect);
833 assert_eq!(args.use_solc.as_deref(), Some("0.8.23"));
834 }
835
836 #[test]
837 fn classify_verifier_probe_accepts_not_verified_response() {
838 let body =
839 r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"#;
840 assert_eq!(
841 classify_verifier_credential_response(StatusCode::OK, body),
842 VerifierCredentialProbe::Accepted,
843 );
844 }
845
846 #[test]
847 fn classify_verifier_probe_rejects_invalid_api_key() {
848 let body = r#"{"status":"0","message":"NOTOK","result":"Invalid API Key"}"#;
849 assert_eq!(
850 classify_verifier_credential_response(StatusCode::OK, body),
851 VerifierCredentialProbe::InvalidApiKey,
852 );
853 assert_eq!(
854 classify_verifier_credential_response(StatusCode::UNAUTHORIZED, ""),
855 VerifierCredentialProbe::InvalidApiKey,
856 );
857 }
858
859 #[test]
860 fn classify_verifier_probe_treats_transient_errors_as_inconclusive() {
861 let body = r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#;
862 assert_eq!(
863 classify_verifier_credential_response(StatusCode::OK, body),
864 VerifierCredentialProbe::Inconclusive,
865 );
866 assert_eq!(
867 classify_verifier_credential_response(
868 StatusCode::OK,
869 "Checking if the site connection is secure",
870 ),
871 VerifierCredentialProbe::Inconclusive,
872 );
873 assert_eq!(
874 classify_verifier_credential_response(
875 StatusCode::FORBIDDEN,
876 "Sorry, you have been blocked",
877 ),
878 VerifierCredentialProbe::Inconclusive,
879 );
880 assert_eq!(
881 classify_verifier_credential_response(StatusCode::FORBIDDEN, ""),
882 VerifierCredentialProbe::Inconclusive,
883 );
884 }
885
886 #[test]
887 fn parse_http_verifier_url_rejects_unsupported_schemes() {
888 assert!(parse_http_verifier_url("https://example.com/api", "verifier").is_ok());
889 assert!(parse_http_verifier_url("http://example.com/api", "verifier").is_ok());
890
891 let err = parse_http_verifier_url("gopher://example.com/api", "verifier").unwrap_err();
892 assert!(
893 err.to_string().contains("URL scheme must be http or https"),
894 "unexpected error: {err:?}"
895 );
896 }
897
898 #[test]
899 fn resolve_explicit_sourcify_overrides_api_key() {
900 let args = VerifierArgs {
901 verifier: Some(VerificationProviderType::Sourcify),
902 verifier_api_key: None,
903 verifier_url: None,
904 };
905 assert_eq!(
906 args.resolve(Some("mykey"), Some(Chain::mainnet())),
907 VerificationProviderType::Sourcify,
908 );
909 }
910
911 #[test]
912 fn resolve_explicit_etherscan_is_etherscan() {
913 let args = VerifierArgs {
914 verifier: Some(VerificationProviderType::Etherscan),
915 verifier_api_key: None,
916 verifier_url: None,
917 };
918 assert_eq!(
919 args.resolve(Some("mykey"), Some(Chain::mainnet())),
920 VerificationProviderType::Etherscan,
921 );
922 }
923
924 #[test]
925 fn resolve_implicit_with_key_and_known_chain_uses_etherscan() {
926 let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
927 assert_eq!(
928 args.resolve(Some("mykey"), Some(Chain::mainnet())),
929 VerificationProviderType::Etherscan,
930 );
931 }
932
933 #[test]
934 fn resolve_implicit_with_key_and_unknown_chain_falls_back_to_sourcify() {
935 let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
936 assert_eq!(
937 args.resolve(Some("mykey"), Some(Chain::from(3658348u64))),
938 VerificationProviderType::Sourcify,
939 );
940 }
941
942 #[test]
943 fn resolve_implicit_with_key_and_unknown_chain_but_url_uses_etherscan() {
944 let args = VerifierArgs {
945 verifier: None,
946 verifier_api_key: None,
947 verifier_url: Some("https://example.com/api".to_string()),
948 };
949 assert_eq!(
950 args.resolve(Some("mykey"), Some(Chain::from(3658348u64))),
951 VerificationProviderType::Etherscan,
952 );
953 }
954
955 #[test]
956 fn resolve_implicit_no_key_falls_back_to_sourcify() {
957 let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
958 assert_eq!(args.resolve(None, Some(Chain::mainnet())), VerificationProviderType::Sourcify,);
959 }
960
961 #[test]
964 fn resolve_implicit_with_key_and_custom_sourcify_chain_falls_back_to_sourcify() {
965 let tempo = Chain::from(4217u64); assert!(tempo.is_custom_sourcify(), "sanity: Tempo should be is_custom_sourcify");
967 let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None };
968 assert_eq!(args.resolve(Some("mykey"), Some(tempo)), VerificationProviderType::Sourcify,);
969 }
970
971 #[test]
975 fn resolve_custom_sourcify_chain_with_url_and_key_stays_sourcify() {
976 let tempo = Chain::from(4217u64);
977 let args = VerifierArgs {
978 verifier: None,
979 verifier_api_key: None,
980 verifier_url: Some("https://contracts.tempo.xyz/".to_string()),
981 };
982 assert_eq!(args.resolve(Some("mykey"), Some(tempo)), VerificationProviderType::Sourcify,);
983 }
984}