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,
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
29/// State after we have broadcasted the script.
30/// It is assumed that at this point [BroadcastedState::sequence] contains receipts for all
31/// broadcasted transactions.
32pub 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/// Data struct to help `ScriptSequence` verify contracts on `etherscan`.
61#[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    /// Configures the chain and sets the etherscan key, if available
119    pub fn set_chain(&mut self, config: &Config, chain: Chain) -> Result<()> {
120        // If dealing with multiple chains, we need to be able to change in between the config
121        // chain_id.
122        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        // A selected Etherscan-compatible verifier is both the source and submission endpoint.
127        // Sourcify credentials must not be sent to the independent Etherscan discovery fallback.
128        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            // Etherscan is optional when Sourcify is selected, so an invalid fallback must not
137            // prevent verification through the selected provider.
138            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            // An explicit non-Sourcify endpoint may be private. Do not disclose provenance to the
155            // public Sourcify service as an implicit fallback.
156            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    /// Given a `VerifyBundle` and contract details, it tries to generate a valid `VerifyArgs` to
180    /// use against the `contract_address`.
181    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 it's a CREATE2, the tx.data comes with a 32-byte salt in the beginning
193            // of the transaction
194            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                // Strip artifact profile from contract name when creating contract info.
203                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                // We strip the build metadata information, since it can lead to
213                // etherscan not identifying it correctly. eg:
214                // `v0.8.10+commit.fc410830.Linux.gcc` != `v0.8.10+commit.fc410830`
215                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
420/// Given the broadcast log, it matches transactions with receipts, and tries to verify any
421/// created contract on etherscan.
422async 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            // create2 hash offset
470            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            // Verify contract created directly from the transaction
481            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            // Verify potential contracts created during the transaction execution
497            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 &current_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}