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