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