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