Skip to main content

forge_script/
verify.rs

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
28/// State after we have broadcasted the script.
29/// It is assumed that at this point [BroadcastedState::sequence] contains receipts for all
30/// broadcasted transactions.
31pub 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        // Every sequence is verified even after one fails, so a failing chain does not leave the
52        // contracts of later chains unsubmitted.
53        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/// Data struct to help `ScriptSequence` verify contracts on `etherscan`.
75#[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    /// Configures the chain and sets the etherscan key, if available
133    pub fn set_chain(&mut self, config: &Config, chain: Chain) -> Result<()> {
134        // If dealing with multiple chains, we need to be able to change in between the config
135        // chain_id.
136        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        // A selected Etherscan-compatible verifier is both the source and submission endpoint.
141        // Sourcify credentials must not be sent to the independent Etherscan discovery fallback.
142        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            // Etherscan is optional when Sourcify is selected, so an invalid fallback must not
151            // prevent verification through the selected provider.
152            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            // An explicit non-Sourcify endpoint may be private. Do not disclose provenance to the
169            // public Sourcify service as an implicit fallback.
170            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    /// Given a `VerifyBundle` and contract details, it tries to generate a valid `VerifyArgs` to
194    /// use against the `contract_address`.
195    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        // Strip artifact profile from contract name when creating contract info.
214        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        // We strip the build metadata information, since it can lead to
224        // etherscan not identifying it correctly. eg:
225        // `v0.8.10+commit.fc410830.Linux.gcc` != `v0.8.10+commit.fc410830`
226        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
425/// Given the broadcast log, it matches transactions with receipts, and tries to verify any
426/// created contract on etherscan.
427async 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        // A contract whose creation code exactly matched a project artifact at broadcast time must
444        // still verify; a mismatch means the sources changed since. Callers pass its name only in
445        // that case. Other unmatched contracts are skipped with a warning.
446        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            // create2 hash offset
486            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            // Verify contract created directly from the transaction
497            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            // Verify potential contracts created during the transaction execution
516            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 &current_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        // An explicit alias with neither a URL nor a known chain fails during chain setup,
916        // before any contract verification jobs can be collected.
917        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        // Both labels must be present: returning the first setup error would hide the later chain.
973        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}