1use crate::{
2 ScriptArgs, ScriptConfig,
3 build::LinkedBuildData,
4 sequence::{ScriptSequenceKind, get_commit_hash},
5};
6use alloy_network::{Network, ReceiptResponse};
7use alloy_primitives::{Address, TxHash, hex};
8use eyre::{Result, eyre};
9use forge_script_sequence::{AdditionalContract, ScriptSequence};
10use forge_verify::{
11 RetryArgs, VerifierArgs, VerifyArgs, provider::ExternalVerificationContext,
12 sourcify::SOURCIFY_URL, verify::sourcify_api_url,
13};
14use foundry_cli::opts::{EtherscanOpts, ProjectPathOpts};
15use foundry_common::{ContractsByArtifact, FoundryReceiptResponse};
16use foundry_compilers::{Project, artifacts::EvmVersion, info::ContractInfo};
17use foundry_config::{Chain, Config};
18use foundry_evm::core::evm::FoundryEvmNetwork;
19use semver::Version;
20
21mod external;
22
23use external::{ExternalResolver, MAX_PROVENANCE_ADDRESSES, MatchResult, match_candidates};
24
25const MAX_EXTERNAL_JOBS: usize = 32;
26
27pub struct BroadcastedState<FEN: FoundryEvmNetwork> {
31 pub args: ScriptArgs,
32 pub script_config: ScriptConfig<FEN>,
33 pub build_data: LinkedBuildData,
34 pub sequence: ScriptSequenceKind<FEN::Network>,
35}
36
37impl<FEN: FoundryEvmNetwork> BroadcastedState<FEN> {
38 pub async fn verify(self) -> Result<()> {
39 let Self { args, script_config, build_data, mut sequence, .. } = self;
40
41 let verify = VerifyBundle::new(
42 &script_config.config.project()?,
43 &script_config.config,
44 build_data.known_contracts,
45 args.retry,
46 args.verifier,
47 args.verify_external,
48 );
49
50 for sequence in sequence.sequences_mut() {
51 verify_contracts::<FEN>(sequence, &script_config.config, verify.clone()).await?;
52 }
53
54 Ok(())
55 }
56}
57
58#[derive(Clone)]
60pub struct VerifyBundle {
61 pub num_of_optimizations: Option<usize>,
62 pub known_contracts: ContractsByArtifact,
63 pub project_paths: ProjectPathOpts,
64 pub etherscan: EtherscanOpts,
65 pub retry: RetryArgs,
66 pub verifier: VerifierArgs,
67 pub via_ir: bool,
68 pub verify_external: bool,
69 source_etherscan_url: Option<String>,
70 source_etherscan_key: Option<String>,
71 source_sourcify_url: Option<String>,
72}
73
74impl VerifyBundle {
75 pub fn new(
76 project: &Project,
77 config: &Config,
78 known_contracts: ContractsByArtifact,
79 retry: RetryArgs,
80 verifier: VerifierArgs,
81 verify_external: bool,
82 ) -> Self {
83 let num_of_optimizations =
84 if config.optimizer == Some(true) { config.optimizer_runs } else { None };
85
86 let config_path = config.get_config_path();
87
88 let project_paths = ProjectPathOpts {
89 root: Some(project.paths.root.clone()),
90 contracts: Some(project.paths.sources.clone()),
91 remappings: project.paths.remappings.clone(),
92 remappings_env: None,
93 cache_path: Some(project.paths.cache.clone()),
94 lib_paths: project.paths.libraries.clone(),
95 hardhat: config.profile == Config::HARDHAT_PROFILE,
96 config_path: config_path.exists().then_some(config_path),
97 };
98
99 let via_ir = config.via_ir;
100
101 Self {
102 num_of_optimizations,
103 known_contracts,
104 etherscan: Default::default(),
105 project_paths,
106 retry,
107 verifier,
108 via_ir,
109 verify_external,
110 source_etherscan_url: None,
111 source_etherscan_key: None,
112 source_sourcify_url: None,
113 }
114 }
115
116 pub fn set_chain(&mut self, config: &Config, chain: Chain) -> Result<()> {
118 let config_key = source_api_key(config, chain);
121 let resolved_key = self.verifier.resolve_api_key(config_key.as_deref()).map(str::to_owned);
122 let provider = self.verifier.resolve(resolved_key.as_deref(), Some(chain));
123
124 if provider.is_sourcify() {
127 self.source_sourcify_url = Some(
128 self.verifier
129 .verifier_url
130 .clone()
131 .or_else(|| sourcify_api_url(chain))
132 .unwrap_or_else(|| SOURCIFY_URL.to_string()),
133 );
134 self.source_etherscan_url = config
137 .get_etherscan_config_with_chain(Some(chain))
138 .ok()
139 .flatten()
140 .map(|config| config.api_url);
141 self.source_etherscan_key = config_key;
142 if self
143 .source_sourcify_url
144 .as_deref()
145 .zip(self.source_etherscan_url.as_deref())
146 .is_some_and(|(sourcify, etherscan)| same_endpoint(sourcify, etherscan))
147 {
148 self.source_etherscan_url = None;
149 self.source_etherscan_key = None;
150 }
151 } else if let Some(url) = &self.verifier.verifier_url {
152 self.source_sourcify_url = None;
155 self.source_etherscan_url = Some(url.clone());
156 self.source_etherscan_key = resolved_key.clone();
157 } else {
158 self.source_sourcify_url =
159 Some(sourcify_api_url(chain).unwrap_or_else(|| SOURCIFY_URL.to_string()));
160 self.source_etherscan_url = config
161 .get_etherscan_config_with_chain(Some(chain))?
162 .map(|config| config.api_url)
163 .or_else(|| {
164 if provider.is_etherscan() && !chain.is_custom_sourcify() {
165 chain.etherscan_urls().map(|(api_url, _)| api_url.to_string())
166 } else {
167 None
168 }
169 });
170 self.source_etherscan_key = resolved_key.clone();
171 }
172 self.etherscan.key = resolved_key;
173 self.etherscan.chain = Some(chain);
174 Ok(())
175 }
176
177 pub fn get_verify_args(
180 &self,
181 contract_address: Address,
182 create2_offset: usize,
183 data: &[u8],
184 libraries: &[String],
185 evm_version: EvmVersion,
186 ) -> Option<VerifyArgs> {
187 let init_code = data.get(create2_offset..)?;
188 for (artifact, contract) in self.known_contracts.iter() {
189 let Some(bytecode) = contract.bytecode() else { continue };
190 if init_code.starts_with(bytecode) {
193 let constructor_args = init_code[bytecode.len()..].to_vec();
194
195 if artifact.source.extension().is_some_and(|e| e.to_str() == Some("vy")) {
196 warn!("Skipping verification of Vyper contract: {}", artifact.name);
197 return None;
198 }
199
200 let contract = ContractInfo {
202 path: Some(artifact.source.to_string_lossy().to_string()),
203 name: artifact
204 .name
205 .strip_suffix(&format!(".{}", artifact.profile))
206 .unwrap_or_else(|| &artifact.name)
207 .to_string(),
208 };
209
210 let version = Version::new(
214 artifact.version.major,
215 artifact.version.minor,
216 artifact.version.patch,
217 );
218
219 let verify = VerifyArgs {
220 address: contract_address,
221 contract: Some(contract),
222 compiler_version: Some(version.to_string()),
223 constructor_args: Some(hex::encode(constructor_args)),
224 constructor_args_path: None,
225 no_auto_detect: false,
226 use_solc: None,
227 num_of_optimizations: self.num_of_optimizations,
228 etherscan: self.etherscan.clone(),
229 rpc: Default::default(),
230 flatten: false,
231 force: false,
232 skip_is_verified_check: true,
233 watch: true,
234 print_submission_result_to_stdout: false,
235 retry: self.retry,
236 libraries: libraries.to_vec(),
237 root: None,
238 verifier: self.verifier.clone(),
239 via_ir: self.via_ir,
240 license_type: None,
241 evm_version: Some(evm_version),
242 show_standard_json_input: false,
243 guess_constructor_args: false,
244 compilation_profile: Some(artifact.profile.clone()),
245 language: None,
246 creation_transaction_hash: None,
247 };
248
249 return Some(verify);
250 }
251 }
252 None
253 }
254}
255
256fn source_api_key(config: &Config, chain: Chain) -> Option<String> {
257 config.get_etherscan_api_key(Some(chain)).or_else(|| config.etherscan_api_key.clone())
258}
259
260enum VerificationJob {
261 Local(VerifyArgs),
262 External(VerifyArgs, Box<ExternalVerificationContext>),
263}
264
265impl VerificationJob {
266 async fn run(self) -> Result<()> {
267 match self {
268 Self::Local(args) => args.run().await,
269 Self::External(args, context) => args.run_with_external_context(*context).await,
270 }
271 }
272}
273
274#[allow(clippy::too_many_arguments)]
275async fn external_job(
276 resolver: &mut Option<ExternalResolver>,
277 config: &Config,
278 chain: Chain,
279 verify: &VerifyBundle,
280 address: Address,
281 init_code: &[u8],
282 creators: &[Address],
283 creation_transaction_hash: TxHash,
284) -> Result<VerificationJob, String> {
285 if resolver.is_none() {
286 *resolver = Some(ExternalResolver::new().map_err(|err| concise(&err.to_string()))?);
287 }
288 let resolver = resolver.as_mut().unwrap();
289 let mut candidate_sets = Vec::new();
290 let mut reasons = Vec::new();
291
292 for &creator in creators.iter().take(MAX_PROVENANCE_ADDRESSES) {
293 let sources = [
294 (
295 "Sourcify",
296 resolver
297 .resolve_sourcify(chain, creator, verify.source_sourcify_url.as_deref())
298 .await,
299 ),
300 (
301 "Etherscan",
302 resolver
303 .resolve_etherscan(
304 chain,
305 creator,
306 verify.source_etherscan_url.as_deref(),
307 verify.source_etherscan_key.as_deref(),
308 )
309 .await,
310 ),
311 ];
312 for (provider, source) in sources {
313 match source {
314 Ok(Some(source)) => match resolver.compile(&source).await {
315 Ok((compiled, has_unresolved_links)) => {
316 if has_unresolved_links {
317 reasons.push(format!(
318 "{} {creator}: contracts with unresolved library links are unsupported",
319 source.provider
320 ));
321 }
322 candidate_sets.push(compiled);
323 }
324 Err(err) => reasons.push(format!(
325 "{} {creator}: compile failed ({})",
326 source.provider,
327 concise(&err)
328 )),
329 },
330 Ok(None) => {}
331 Err(err) => reasons.push(format!("{provider} {creator}: {}", concise(&err))),
332 }
333 }
334 }
335
336 let matched = match match_candidates(
337 init_code,
338 candidate_sets.iter().flat_map(|candidates| candidates.iter()),
339 ) {
340 MatchResult::Unique(matched) => matched,
341 MatchResult::None => {
342 let context = if reasons.is_empty() {
343 "no matching candidates were found".to_string()
344 } else {
345 format!("no matching candidates were found; {}", reasons.join("; "))
346 };
347 return Err(context);
348 }
349 MatchResult::Ambiguous(matches) => {
350 let fqns = matches
351 .into_iter()
352 .map(|matched| format!("{}@{}", matched.fqn, matched.version))
353 .collect::<Vec<_>>();
354 return Err(format!("ambiguous external candidates: {}", fqns.join(", ")));
355 }
356 };
357
358 let mut pinned_config = config.clone();
359 pinned_config.chain = Some(chain);
360 let context = ExternalVerificationContext {
361 config: pinned_config,
362 compiler_version: matched.version.clone(),
363 standard_json_input: matched.input,
364 target: matched.fqn,
365 };
366 let args = VerifyArgs {
367 address,
368 contract: None,
369 compiler_version: Some(matched.version.to_string()),
370 constructor_args: Some(hex::encode(matched.constructor_args)),
371 constructor_args_path: None,
372 no_auto_detect: false,
373 use_solc: None,
374 num_of_optimizations: None,
375 etherscan: verify.etherscan.clone(),
376 rpc: Default::default(),
377 flatten: false,
378 force: false,
379 skip_is_verified_check: true,
380 watch: true,
381 print_submission_result_to_stdout: false,
382 retry: verify.retry,
383 libraries: Vec::new(),
384 root: None,
385 verifier: verify.verifier.clone(),
386 via_ir: false,
387 license_type: None,
388 evm_version: None,
389 show_standard_json_input: false,
390 guess_constructor_args: false,
391 compilation_profile: None,
392 language: None,
393 creation_transaction_hash: Some(creation_transaction_hash),
394 };
395 Ok(VerificationJob::External(args, Box::new(context)))
396}
397
398fn concise(reason: &str) -> String {
399 const LIMIT: usize = 160;
400 let mut chars = reason.chars().map(|ch| if ch.is_control() { ' ' } else { ch });
401 let reason = chars.by_ref().take(LIMIT).collect::<String>();
402 if chars.next().is_some() { format!("{reason}…") } else { reason }
403}
404
405fn take_matching_index<T>(
406 values: &[T],
407 consumed: &mut [bool],
408 predicate: impl Fn(&T) -> bool,
409) -> Option<usize> {
410 let index = values
411 .iter()
412 .enumerate()
413 .position(|(index, value)| !consumed[index] && predicate(value))?;
414 consumed[index] = true;
415 Some(index)
416}
417
418async fn verify_contracts<FEN: FoundryEvmNetwork>(
421 sequence: &mut ScriptSequence<FEN::Network>,
422 config: &Config,
423 mut verify: VerifyBundle,
424) -> Result<()> {
425 trace!(target: "script", "verifying {} contracts [{}]", verify.known_contracts.len(), sequence.chain);
426
427 verify.set_chain(config, sequence.chain.into())?;
428
429 if verify.etherscan.has_key() || !verify.verifier.effective_type().is_etherscan() {
430 trace!(target: "script", "prepare future verifications");
431
432 let mut verification_jobs = Vec::with_capacity(sequence.receipts.len());
433 let mut unverifiable_contracts = vec![];
434 let mut resolver = None;
435 let mut external_jobs = 0;
436 let mut skipped_external = 0;
437 let mut warned_offline = false;
438 let mut consumed_receipts = vec![false; sequence.receipts.len()];
439
440 for tx in &sequence.transactions {
441 let Some(tx_hash) = tx.hash else {
442 let _ = sh_warn!("Skipping verification for transaction without a hash.");
443 continue;
444 };
445 let Some(receipt_index) =
446 take_matching_index(&sequence.receipts, &mut consumed_receipts, |receipt| {
447 receipt.transaction_hash() == tx_hash
448 })
449 else {
450 let _ = sh_warn!(
451 "Skipping verification for transaction {tx_hash}: receipt unavailable."
452 );
453 continue;
454 };
455 let receipt = &mut sequence.receipts[receipt_index];
456 let malformed_create2 =
457 tx.is_create2() && tx.tx().input().is_none_or(|data| data.len() < 32);
458 if malformed_create2 {
459 let input_len = tx.tx().input().map_or(0, |data| data.len());
460 let _ = sh_warn!(
461 "Skipping verification for CREATE2 transaction {tx_hash}: input length {input_len} is shorter than the 32-byte salt prefix."
462 );
463 }
464
465 let offset = if !malformed_create2
467 && tx.is_create2()
468 && let Some(contract_address) = tx.contract_address
469 {
470 receipt.set_contract_address(contract_address);
471 32
472 } else {
473 0
474 };
475
476 if !malformed_create2
478 && let (Some(address), Some(data)) = (receipt.contract_address(), tx.tx().input())
479 {
480 match verify.get_verify_args(
481 address,
482 offset,
483 data,
484 &sequence.libraries,
485 config.evm_version,
486 ) {
487 Some(verify) => verification_jobs.push(VerificationJob::Local(verify)),
488 None => unverifiable_contracts.push(address),
489 };
490 }
491
492 for AdditionalContract { address, init_code, creator_code_addresses, .. } in
494 &tx.additional_contracts
495 {
496 match verify.get_verify_args(
497 *address,
498 0,
499 init_code.as_ref(),
500 &sequence.libraries,
501 config.evm_version,
502 ) {
503 Some(args) => verification_jobs.push(VerificationJob::Local(args)),
504 None if !verify.verify_external => unverifiable_contracts.push(*address),
505 None if config.offline => {
506 skipped_external += 1;
507 if !warned_offline {
508 let _ = sh_warn!(
509 "Skipping external contract verification because offline mode is enabled."
510 );
511 warned_offline = true;
512 }
513 }
514 None if creator_code_addresses.is_empty() => {
515 skipped_external += 1;
516 let _ = sh_warn!(
517 "Skipping external verification for {address}: creator provenance is unavailable (old broadcast logs or skipped simulation)."
518 );
519 }
520 None if external_jobs >= MAX_EXTERNAL_JOBS => {
521 skipped_external += 1;
522 let _ = sh_warn!(
523 "Skipping external verification for {address}: external job limit exceeded."
524 );
525 }
526 None => {
527 external_jobs += 1;
528 match external_job(
529 &mut resolver,
530 config,
531 sequence.chain.into(),
532 &verify,
533 *address,
534 init_code,
535 creator_code_addresses,
536 receipt.transaction_hash(),
537 )
538 .await
539 {
540 Ok(job) => verification_jobs.push(job),
541 Err(reason) => {
542 skipped_external += 1;
543 let _ = sh_warn!(
544 "Skipping external verification for {address}: {reason}"
545 );
546 }
547 }
548 }
549 };
550 }
551 }
552
553 trace!(target: "script", "collected {} verification jobs and {} unverifiable contracts", verification_jobs.len(), unverifiable_contracts.len());
554
555 check_unverified(sequence, unverifiable_contracts, verify);
556
557 let num_verifications = verification_jobs.len();
558 let num_requested = num_verifications + skipped_external;
559 let mut num_of_successful_verifications = 0;
560 sh_status!("##\nStart verification for ({num_requested}) contracts")?;
561 for verification in verification_jobs {
562 match verification.run().await {
563 Ok(_) => {
564 num_of_successful_verifications += 1;
565 }
566 Err(err) => {
567 sh_err!("Failed to verify contract: {err:#}")?;
568 }
569 }
570 }
571
572 ensure_verification_complete(
573 num_of_successful_verifications,
574 num_verifications,
575 skipped_external,
576 )?;
577
578 sh_status!("All ({num_requested}) contracts were verified!")?;
579 }
580
581 Ok(())
582}
583
584fn ensure_verification_complete(
585 successful: usize,
586 submitted: usize,
587 skipped_external: usize,
588) -> Result<()> {
589 let requested = submitted + skipped_external;
590 if successful < requested {
591 let skipped = if skipped_external == 0 {
592 String::new()
593 } else {
594 format!("; {skipped_external} external verification(s) were skipped")
595 };
596 return Err(eyre!(
597 "Not all ({successful} / {requested}) contracts were verified{skipped}!"
598 ));
599 }
600 Ok(())
601}
602
603fn check_unverified<N: Network>(
604 sequence: &ScriptSequence<N>,
605 unverifiable_contracts: Vec<Address>,
606 verify: VerifyBundle,
607) {
608 if !unverifiable_contracts.is_empty() {
609 let _ = sh_warn!(
610 "We haven't found any matching bytecode for the following contracts: {:?}.\n\n\
611 This may occur when resuming a verification, but the underlying source code or compiler version has changed.\n\
612 Run `forge clean` to make sure builds are in sync with project files, then try again. Alternatively, use `forge verify-contract` to verify contracts that are already deployed.",
613 unverifiable_contracts
614 );
615
616 if let Some(commit) = &sequence.commit {
617 let current_commit = verify
618 .project_paths
619 .root
620 .map(|root| get_commit_hash(&root).unwrap_or_default())
621 .unwrap_or_default();
622
623 if ¤t_commit != commit {
624 let _ = sh_warn!(
625 "Script was broadcasted on commit `{commit}`, but we are at `{current_commit}`."
626 );
627 }
628 }
629 }
630}
631
632fn same_endpoint(left: &str, right: &str) -> bool {
633 let (Ok(left), Ok(right)) = (reqwest::Url::parse(left), reqwest::Url::parse(right)) else {
634 return false;
635 };
636 left == right
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use alloy_primitives::Bytes;
643 use forge_verify::provider::VerificationProviderType;
644 use foundry_compilers::{
645 ArtifactId,
646 artifacts::{BytecodeObject, CompactBytecode, CompactContractBytecode},
647 };
648
649 fn bundle(config: &Config, verifier: VerifierArgs) -> VerifyBundle {
650 let project = config.project().unwrap();
651 VerifyBundle::new(
652 &project,
653 config,
654 ContractsByArtifact::default(),
655 RetryArgs::default(),
656 verifier,
657 true,
658 )
659 }
660
661 fn bundle_with_bytecode(bytecode: Bytes) -> VerifyBundle {
662 let config = Config::default();
663 let mut verify = bundle(&config, VerifierArgs::default());
664 verify.known_contracts = ContractsByArtifact::new([(
665 ArtifactId {
666 path: "out/Test.json".into(),
667 name: "Test".into(),
668 source: "src/Test.sol".into(),
669 version: Version::new(0, 8, 30),
670 build_id: String::new(),
671 profile: "default".into(),
672 },
673 CompactContractBytecode {
674 abi: Some(Default::default()),
675 bytecode: Some(CompactBytecode {
676 object: BytecodeObject::Bytecode(bytecode),
677 source_map: None,
678 link_references: Default::default(),
679 }),
680 deployed_bytecode: None,
681 },
682 )]);
683 verify
684 }
685
686 #[test]
687 fn truncated_create2_data_is_unverifiable() {
688 let bytecode = Bytes::from_static(&[0x60, 0x00]);
689 let verify = bundle_with_bytecode(bytecode);
690 let address = Address::ZERO;
691
692 for data in [Bytes::new(), Bytes::from(vec![0; 31])] {
693 assert!(
694 verify.get_verify_args(address, 32, &data, &[], EvmVersion::London).is_none(),
695 "truncated data should not produce verification args"
696 );
697 }
698 }
699
700 #[test]
701 fn salt_only_create2_data_is_unverifiable() {
702 let verify = bundle_with_bytecode(Bytes::from_static(&[0x60, 0x00]));
703 let address = Address::ZERO;
704 let salt_only = Bytes::from(vec![0; 32]);
705 assert!(
706 verify.get_verify_args(address, 32, &salt_only, &[], EvmVersion::London).is_none(),
707 "valid salt-only data should not match non-empty bytecode"
708 );
709 }
710
711 #[test]
712 fn valid_create2_data_produces_verification_args() {
713 let bytecode = Bytes::from_static(&[0x60, 0x00]);
714 let verify = bundle_with_bytecode(bytecode.clone());
715 let address = Address::ZERO;
716 let mut data = vec![0; 32];
717 data.extend_from_slice(&bytecode);
718 data.extend_from_slice(&[0xaa, 0xbb]);
719 let args = verify
720 .get_verify_args(address, 32, &data, &[], EvmVersion::London)
721 .expect("valid CREATE2 data should produce verification args");
722 assert_eq!(args.constructor_args.as_deref(), Some("aabb"));
723 }
724
725 #[test]
726 fn receipt_matching_is_hash_based_and_consumes_duplicate_hashes_in_order() {
727 let reversed = [(2, "second"), (1, "first")];
728 let mut consumed = [false; 2];
729 assert_eq!(take_matching_index(&reversed, &mut consumed, |(hash, _)| *hash == 1), Some(1));
730 assert_eq!(take_matching_index(&reversed, &mut consumed, |(hash, _)| *hash == 2), Some(0));
731 assert_eq!(consumed, [true, true]);
732
733 let batch = [(7, "first"), (7, "second")];
734 let mut consumed = [false; 2];
735 let first = take_matching_index(&batch, &mut consumed, |(hash, _)| *hash == 7).unwrap();
736 let second = take_matching_index(&batch, &mut consumed, |(hash, _)| *hash == 7).unwrap();
737 assert_eq!((batch[first].1, batch[second].1), ("first", "second"));
738 assert!(take_matching_index(&batch, &mut consumed, |(hash, _)| *hash == 7).is_none());
739 }
740
741 #[test]
742 fn source_key_reads_etherscan_config_fallback() {
743 let mut config = Config { etherscan_api_key: Some("source".into()), ..Default::default() };
744 assert_eq!(source_api_key(&config, Chain::mainnet()).as_deref(), Some("source"));
745 config.etherscan_api_key = None;
746 assert!(source_api_key(&config, Chain::mainnet()).is_none());
747 }
748
749 #[test]
750 fn source_endpoints_follow_selected_provider_privacy() {
751 let tempo = Chain::from(4217u64);
752 let config = Config { etherscan_api_key: Some("ambient".into()), ..Default::default() };
753 let mut verify = bundle(&config, VerifierArgs::default());
754 verify.set_chain(&config, tempo).unwrap();
755 assert_eq!(verify.source_sourcify_url, sourcify_api_url(tempo));
756 assert_ne!(verify.source_sourcify_url.as_deref(), Some(SOURCIFY_URL));
757 assert!(verify.source_etherscan_url.is_none());
758 assert!(verify.source_etherscan_key.is_none());
759
760 let config = Config::default();
761 let mut verify = bundle(
762 &config,
763 VerifierArgs {
764 verifier: Some(VerificationProviderType::Custom),
765 verifier_api_key: Some("private-key".into()),
766 verifier_url: Some("https://private.example/api".into()),
767 },
768 );
769 verify.set_chain(&config, Chain::mainnet()).unwrap();
770 assert!(verify.source_sourcify_url.is_none());
771 assert_eq!(verify.source_etherscan_url.as_deref(), Some("https://private.example/api"));
772
773 let mut verify = bundle(
774 &config,
775 VerifierArgs {
776 verifier: Some(VerificationProviderType::Etherscan),
777 ..Default::default()
778 },
779 );
780 verify.set_chain(&config, Chain::mainnet()).unwrap();
781 assert_eq!(verify.source_sourcify_url.as_deref(), Some(SOURCIFY_URL));
782 }
783
784 #[test]
785 fn source_endpoint_comparison_normalizes_urls_without_ignoring_routes() {
786 assert!(same_endpoint("https://CONTRACTS.tempo.xyz:443", "https://contracts.tempo.xyz/"));
787 assert!(!same_endpoint("https://contracts.tempo.xyz/api", "https://contracts.tempo.xyz/"));
788 assert!(!same_endpoint(
789 "https://contracts.tempo.xyz/?chainid=4217",
790 "https://contracts.tempo.xyz/"
791 ));
792 assert!(!same_endpoint("not a URL", "https://contracts.tempo.xyz/"));
793 }
794
795 #[test]
796 fn cli_only_etherscan_key_uses_chain_source_endpoint() {
797 let chain = Chain::mainnet();
798 let config = Config::default();
799 let mut verify = bundle(
800 &config,
801 VerifierArgs { verifier_api_key: Some("cli-key".into()), ..Default::default() },
802 );
803
804 verify.set_chain(&config, chain).unwrap();
805
806 assert_eq!(
807 verify.source_etherscan_url.as_deref(),
808 Some("https://api.etherscan.io/v2/api?chainid=1")
809 );
810 assert_eq!(verify.source_etherscan_key.as_deref(), Some("cli-key"));
811 assert_eq!(verify.etherscan.key.as_deref(), Some("cli-key"));
812 }
813
814 #[test]
815 fn chain_source_endpoint_requires_valid_etherscan_route() {
816 let config = Config::default();
817 for (chain, provider) in [
818 (Chain::mainnet(), VerificationProviderType::Custom),
819 (Chain::from(4217u64), VerificationProviderType::Etherscan),
820 ] {
821 let mut verify = bundle(
822 &config,
823 VerifierArgs {
824 verifier: Some(provider),
825 verifier_api_key: Some("private-key".into()),
826 ..Default::default()
827 },
828 );
829
830 verify.set_chain(&config, chain).unwrap();
831
832 assert!(verify.source_etherscan_url.is_none());
833 }
834 }
835
836 #[test]
837 fn skipped_external_verifications_make_the_summary_fail() {
838 let err = ensure_verification_complete(0, 0, 1).unwrap_err().to_string();
839 assert!(err.contains("0 / 1"));
840 assert!(err.contains("1 external verification(s) were skipped"));
841 ensure_verification_complete(1, 1, 0).unwrap();
842 }
843
844 #[test]
845 fn concise_sanitizes_and_bounds_remote_errors() {
846 let message = format!("remote\n\u{1b}[31m{}", "x".repeat(200));
847 let concise = concise(&message);
848 assert!(!concise.chars().any(char::is_control));
849 assert!(concise.chars().count() <= 161);
850 }
851}