Skip to main content

forge_script/
lib.rs

1//! # foundry-script
2//!
3//! Smart contract scripting.
4
5#![recursion_limit = "256"]
6#![cfg_attr(not(test), warn(unused_crate_dependencies))]
7#![cfg_attr(docsrs, feature(doc_cfg))]
8
9#[macro_use]
10extern crate foundry_common;
11
12#[macro_use]
13extern crate tracing;
14
15use crate::{broadcast::BundledState, runner::ScriptRunner};
16use alloy_chains::{Chain, NamedChain};
17use alloy_json_abi::{Function, JsonAbi};
18use alloy_network::Network;
19use alloy_primitives::{
20    Address, Bytes, Log, U256, hex,
21    map::{AddressHashMap, HashMap},
22};
23use alloy_signer::Signer;
24use broadcast::next_nonce;
25use build::PreprocessedState;
26use clap::{Parser, ValueHint};
27use dialoguer::Confirm;
28use eyre::{ContextCompat, Result};
29use forge_script_sequence::{AdditionalContract, NestedValue};
30use forge_verify::{RetryArgs, VerifierArgs};
31use foundry_cli::{
32    opts::{BuildOpts, EvmArgs, GlobalArgs, TempoOpts},
33    utils::LoadConfig,
34};
35use foundry_common::{
36    ContractsByArtifact, SELECTOR_LEN,
37    abi::{encode_function_args, get_func},
38    compile::ContractSizeLimits,
39    shell,
40    tempo::resolve_fee_token,
41};
42use foundry_compilers::ArtifactId;
43use foundry_config::{
44    Config, figment,
45    figment::{
46        Metadata, Profile, Provider,
47        value::{Dict, Map},
48    },
49};
50#[cfg(feature = "optimism")]
51use foundry_evm::core::evm::OpEvmNetwork;
52use foundry_evm::{
53    backend::Backend,
54    core::{
55        Breakpoints, FoundryTransaction,
56        evm::{EthEvmNetwork, FoundryEvmNetwork, TempoEvmNetwork, TxEnvFor},
57    },
58    executors::ExecutorBuilder,
59    inspectors::{
60        CheatsConfig,
61        cheatcodes::{BroadcastableTransactions, Wallets},
62    },
63    opts::EvmOpts,
64    revm::interpreter::InstructionResult,
65    traces::{TraceMode, Traces},
66};
67use foundry_evm_networks::NetworkConfigs;
68use foundry_wallets::MultiWalletOpts;
69use serde::Serialize;
70use std::path::PathBuf;
71
72mod broadcast;
73mod build;
74mod execute;
75mod multi_sequence;
76mod progress;
77mod providers;
78mod receipts;
79mod runner;
80mod sequence;
81mod session;
82mod simulate;
83mod transaction;
84mod verify;
85mod wallet_session;
86
87pub use wallet_session::ScriptWalletSessionArgs;
88
89// Loads project's figment and merges the build cli arguments into it
90foundry_config::merge_impl_figment_convert!(ScriptArgs, build, evm);
91
92/// CLI arguments for `forge script`.
93#[derive(Clone, Debug, Default, Parser)]
94pub struct ScriptArgs {
95    // Include global options for users of this struct.
96    #[command(flatten)]
97    pub global: GlobalArgs,
98
99    /// The contract you want to run. Either the file path or contract name.
100    ///
101    /// If multiple contracts exist in the same file you must specify the target contract with
102    /// --target-contract.
103    #[arg(value_hint = ValueHint::FilePath)]
104    pub path: String,
105
106    /// Arguments to pass to the script function.
107    pub args: Vec<String>,
108
109    /// The name of the contract you want to run.
110    #[arg(long, visible_alias = "tc", value_name = "CONTRACT_NAME")]
111    pub target_contract: Option<String>,
112
113    /// The signature of the function you want to call in the contract, or raw calldata.
114    #[arg(long, short, default_value = "run")]
115    pub sig: String,
116
117    /// Max priority fee per gas for EIP1559 transactions.
118    #[arg(
119        long,
120        env = "ETH_PRIORITY_GAS_PRICE",
121        value_parser = foundry_cli::utils::parse_ether_value,
122        value_name = "PRICE"
123    )]
124    pub priority_gas_price: Option<U256>,
125
126    /// Use legacy transactions instead of EIP1559 ones.
127    ///
128    /// This is auto-enabled for common networks without EIP1559.
129    #[arg(long)]
130    pub legacy: bool,
131
132    /// Broadcasts the transactions.
133    #[arg(long)]
134    pub broadcast: bool,
135
136    /// Batch all broadcast transactions into a single Tempo batch transaction.
137    ///
138    /// When enabled, all vm.broadcast() calls are collected and sent as a single
139    /// atomic type 0x76 transaction instead of individual transactions.
140    /// This provides atomicity (all-or-nothing execution) and gas savings.
141    #[arg(long)]
142    pub batch: bool,
143
144    /// Tempo transaction options.
145    #[command(flatten)]
146    pub tempo: TempoOpts,
147
148    /// Create a temporary Tempo wallet session, run this script with it, then revoke it.
149    #[command(flatten)]
150    pub wallet_session: ScriptWalletSessionArgs,
151
152    /// Skips on-chain simulation.
153    #[arg(long)]
154    pub skip_simulation: bool,
155
156    /// Relative percentage to multiply gas estimates by.
157    #[arg(long, short, default_value = "130")]
158    pub gas_estimate_multiplier: u64,
159
160    /// Send via `eth_sendTransaction` using the `--sender` argument as sender.
161    #[arg(
162        long,
163        conflicts_with_all = &["private_key", "private_keys", "ledger", "trezor", "aws", "browser"],
164    )]
165    pub unlocked: bool,
166
167    /// Resumes submitting transactions that failed or timed-out previously.
168    ///
169    /// It DOES NOT simulate the script again and it expects nonces to have remained the same.
170    ///
171    /// Example: If transaction N has a nonce of 22, then the account should have a nonce of 22,
172    /// otherwise it fails.
173    #[arg(long)]
174    pub resume: bool,
175
176    /// If present, --resume or --verify will be assumed to be a multi chain deployment.
177    #[arg(long)]
178    pub multi: bool,
179
180    /// Open the script in the debugger.
181    ///
182    /// Takes precedence over broadcast.
183    #[arg(long)]
184    pub debug: bool,
185
186    /// Dumps all debugger steps to file.
187    #[arg(
188        long,
189        requires = "debug",
190        value_hint = ValueHint::FilePath,
191        value_name = "PATH"
192    )]
193    pub dump: Option<PathBuf>,
194
195    /// Makes sure a transaction is sent,
196    /// only after its previous one has been confirmed and succeeded.
197    #[arg(long)]
198    pub slow: bool,
199
200    /// Disables interactive prompts that might appear when deploying big contracts.
201    ///
202    /// For more info on the contract size limit, see EIP-170: <https://eips.ethereum.org/EIPS/eip-170>
203    #[arg(long)]
204    pub non_interactive: bool,
205
206    /// Disables the contract size limit during script execution.
207    #[arg(long)]
208    pub disable_code_size_limit: bool,
209
210    /// Disables the labels in the traces.
211    #[arg(long)]
212    pub disable_labels: bool,
213
214    /// The Etherscan (or equivalent) API key
215    #[arg(long, env = "ETHERSCAN_API_KEY", value_name = "KEY")]
216    pub etherscan_api_key: Option<String>,
217
218    /// Verifies all the contracts found in the receipts of a script, if any.
219    #[arg(long, requires = "broadcast")]
220    pub verify: bool,
221
222    /// Gas price for legacy transactions, or max fee per gas for EIP1559 transactions, either
223    /// specified in wei, or as a string with a unit type.
224    ///
225    /// Examples: 1ether, 10gwei, 0.01ether
226    #[arg(
227        long,
228        env = "ETH_GAS_PRICE",
229        value_parser = foundry_cli::utils::parse_ether_value,
230        value_name = "PRICE",
231    )]
232    pub with_gas_price: Option<U256>,
233
234    /// Timeout to use for broadcasting transactions.
235    #[arg(long, env = "ETH_TIMEOUT")]
236    pub timeout: Option<u64>,
237
238    #[command(flatten)]
239    pub build: BuildOpts,
240
241    #[command(flatten)]
242    pub wallets: MultiWalletOpts,
243
244    #[command(flatten)]
245    pub evm: EvmArgs,
246
247    #[command(flatten)]
248    pub verifier: VerifierArgs,
249
250    #[command(flatten)]
251    pub retry: RetryArgs,
252}
253
254impl ScriptArgs {
255    fn has_tempo_session(&self) -> Result<bool> {
256        Ok(self.tempo.session_id()?.is_some())
257    }
258
259    /// Loads config, resolves evm_opts (including network inference from fork), and returns them.
260    async fn resolved_evm_opts(&self) -> Result<(Config, EvmOpts)> {
261        let (config, mut evm_opts) = self.load_config_and_evm_opts()?;
262
263        if self.tempo.is_tempo() || self.has_tempo_session()? {
264            // If Tempo tx options or a session are set, select the Tempo network.
265            evm_opts.networks = NetworkConfigs::with_tempo();
266        } else {
267            // Auto-detect network from fork chain ID when not explicitly configured.
268            evm_opts.infer_network_from_fork().await;
269        }
270
271        Ok((config, evm_opts))
272    }
273
274    async fn preprocess<FEN: FoundryEvmNetwork>(
275        self,
276        config: Config,
277        mut evm_opts: EvmOpts,
278    ) -> Result<PreprocessedState<FEN>> {
279        let args = self;
280        let mut tempo = args.tempo.clone();
281
282        let session_sender = if args.resume {
283            None
284        } else {
285            // Initial scripts may only reveal multi-chain transactions during execution. Use the
286            // session root as the script sender here and validate chain scope during broadcast.
287            tempo.session_sender_for_multi_wallet(&args.wallets, args.evm.sender)?
288        };
289
290        let script_wallets = Wallets::new(args.wallets.get_multi_wallet().await?, args.evm.sender);
291        let browser_wallet = args.wallets.browser_signer::<FEN::Network>().await?;
292
293        if let Some(sender) = session_sender {
294            evm_opts.sender = sender;
295        } else if let Some(sender) = args.maybe_load_private_key()? {
296            evm_opts.sender = sender;
297        } else if args.evm.sender.is_none() {
298            // If no sender was explicitly set via --sender, auto-detect it from available signers:
299            // use the sole signer's address if there's exactly one, or fall back to the browser
300            // wallet address if present.
301            if let Ok(signers) = script_wallets.signers()
302                && signers.len() == 1
303            {
304                evm_opts.sender = signers[0];
305            } else if let Some(signer) = browser_wallet.as_ref().map(|b| b.address()) {
306                evm_opts.sender = signer
307            }
308        }
309
310        tempo.resolve_expires();
311
312        // Resolve the fee token: default only when the active EVM network is Tempo.
313        let chain = evm_opts.networks.is_tempo().then(|| Chain::from_named(NamedChain::Tempo));
314        tempo.fee_token = resolve_fee_token(chain, tempo.fee_token);
315
316        let script_config = ScriptConfig::new(config, evm_opts, args.batch, tempo).await?;
317        Ok(PreprocessedState { args, script_config, script_wallets, browser_wallet })
318    }
319
320    /// Executes the script
321    #[allow(clippy::large_stack_frames)]
322    pub async fn run_script(self) -> Result<()> {
323        trace!(target: "script", "executing script command");
324
325        if self.wallet_session.enabled {
326            return self.run_wallet_session_wrapper();
327        }
328
329        let (config, evm_opts) = self.resolved_evm_opts().await?;
330
331        let is_tempo = evm_opts.networks.is_tempo();
332
333        if self.batch && !is_tempo {
334            eyre::bail!("--batch mode is only supported on Tempo networks");
335        }
336
337        if self.unlocked && self.has_tempo_session()? {
338            eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --unlocked");
339        }
340
341        if is_tempo {
342            let batch = self.batch;
343            let bundled = match self.prepare_bundled::<TempoEvmNetwork>(config, evm_opts).await? {
344                Some(bundled) => bundled,
345                None => return Ok(()),
346            };
347            // batch mode owns its own pending recovery inside broadcast_batch(); running the
348            // generic wait_for_pending() first would race with that and could double-process
349            // an already-confirmed batch hash.
350            let bundled = if batch { bundled } else { bundled.wait_for_pending().await? };
351            let broadcasted =
352                if batch { bundled.broadcast_batch().await? } else { bundled.broadcast().await? };
353            if broadcasted.args.verify {
354                broadcasted.verify().await?;
355            }
356            return Ok(());
357        }
358
359        #[cfg(feature = "optimism")]
360        if evm_opts.networks.is_optimism() {
361            return self.run_generic_script::<OpEvmNetwork>(config, evm_opts).await;
362        }
363
364        self.run_generic_script::<EthEvmNetwork>(config, evm_opts).await
365    }
366
367    /// Prepares the bundled state (compile, simulate, bundle) and returns it
368    /// for broadcasting, or returns `None` if there's nothing to broadcast
369    /// (e.g., debug mode, no transactions, missing RPCs).
370    #[allow(clippy::large_stack_frames)]
371    async fn prepare_bundled<FEN: FoundryEvmNetwork>(
372        self,
373        config: Config,
374        evm_opts: EvmOpts,
375    ) -> Result<Option<BundledState<FEN>>> {
376        let state = self.preprocess::<FEN>(config, evm_opts).await?;
377        let create2_deployer = state.script_config.evm_opts.create2_deployer;
378        let compiled = state.compile()?;
379
380        // Move from `CompiledState` to `BundledState` either by resuming or executing and
381        // simulating script.
382        let bundled = if compiled.args.resume {
383            compiled.resume().await?
384        } else {
385            // Drive state machine to point at which we have everything needed for simulation.
386            let pre_simulation = compiled
387                .link()
388                .await?
389                .prepare_execution()
390                .await?
391                .execute()
392                .await?
393                .prepare_simulation()
394                .await?;
395
396            if pre_simulation.args.debug {
397                return match pre_simulation.args.dump.clone() {
398                    Some(path) => pre_simulation.dump_debugger(&path).map(|_| None),
399                    None => pre_simulation.run_debugger().map(|_| None),
400                };
401            }
402
403            if shell::is_json() {
404                pre_simulation.show_json().await?;
405            } else {
406                pre_simulation.show_traces().await?;
407            }
408
409            // Ensure that we have transactions to simulate/broadcast, otherwise exit early to avoid
410            // hard error.
411            if pre_simulation
412                .execution_result
413                .transactions
414                .as_ref()
415                .is_none_or(|txs| txs.is_empty())
416            {
417                if pre_simulation.args.broadcast {
418                    sh_warn!("No transactions to broadcast.")?;
419                }
420
421                return Ok(None);
422            }
423
424            // Check if there are any missing RPCs and exit early to avoid hard error.
425            if pre_simulation.execution_artifacts.rpc_data.missing_rpc {
426                if !shell::is_json() {
427                    sh_println!("\nIf you wish to simulate on-chain transactions pass a RPC URL.")?;
428                }
429
430                return Ok(None);
431            }
432
433            let size_limits = pre_simulation
434                .script_config
435                .config
436                .code_size_limit
437                .map(ContractSizeLimits::with_runtime_limit)
438                .unwrap_or_default();
439            pre_simulation.args.check_contract_sizes(
440                size_limits,
441                &pre_simulation.execution_result,
442                &pre_simulation.build_data.known_contracts,
443                create2_deployer,
444            )?;
445
446            pre_simulation.fill_metadata().await?.bundle().await?
447        };
448
449        // Exit early in case user didn't provide any broadcast/verify related flags.
450        if !bundled.args.should_broadcast() {
451            if !shell::is_json() {
452                if shell::verbosity() >= 4 {
453                    sh_println!("\n=== Transactions that will be broadcast ===\n")?;
454                    bundled.sequence.show_transactions()?;
455                }
456
457                sh_println!(
458                    "\nSIMULATION COMPLETE. To broadcast these transactions, add --broadcast and wallet configuration(s) to the previous command. See forge script --help for more."
459                )?;
460            }
461            return Ok(None);
462        }
463
464        // Exit early if something is wrong with verification options.
465        if bundled.args.verify {
466            bundled.verify_preflight_check().await?;
467        }
468
469        Ok(Some(bundled))
470    }
471
472    async fn run_generic_script<FEN: FoundryEvmNetwork>(
473        self,
474        config: Config,
475        evm_opts: EvmOpts,
476    ) -> Result<()> {
477        let bundled = match self.prepare_bundled::<FEN>(config, evm_opts).await? {
478            Some(bundled) => bundled,
479            None => return Ok(()),
480        };
481
482        // Wait for pending txes and broadcast others.
483        let broadcasted = bundled.wait_for_pending().await?.broadcast().await?;
484
485        if broadcasted.args.verify {
486            broadcasted.verify().await?;
487        }
488
489        Ok(())
490    }
491
492    /// In case the user has loaded *only* one private-key or a single remote signer (e.g.,
493    /// Turnkey), we can assume that they're using it as the `--sender`.
494    fn maybe_load_private_key(&self) -> Result<Option<Address>> {
495        if let Some(turnkey_address) = self.wallets.turnkey_address() {
496            return Ok(Some(turnkey_address));
497        }
498
499        let maybe_sender = self
500            .wallets
501            .private_keys()?
502            .filter(|pks| pks.len() == 1)
503            .map(|pks| pks.first().unwrap().address());
504        Ok(maybe_sender)
505    }
506
507    /// Returns the Function and calldata based on the signature
508    ///
509    /// If the `sig` is a valid human-readable function we find the corresponding function in the
510    /// `abi` If the `sig` is valid hex, we assume it's calldata and try to find the
511    /// corresponding function by matching the selector, first 4 bytes in the calldata.
512    ///
513    /// Note: We assume that the `sig` is already stripped of its prefix, See [`ScriptArgs`]
514    fn get_method_and_calldata(&self, abi: &JsonAbi) -> Result<(Function, Bytes)> {
515        if let Ok(decoded) = hex::decode(&self.sig) {
516            let selector = &decoded[..SELECTOR_LEN];
517            let func =
518                abi.functions().find(|func| selector == &func.selector()[..]).ok_or_else(|| {
519                    eyre::eyre!(
520                        "Function selector `{}` not found in the ABI",
521                        hex::encode(selector)
522                    )
523                })?;
524            return Ok((func.clone(), decoded.into()));
525        }
526
527        let func = if self.sig.contains('(') {
528            let func = get_func(&self.sig)?;
529            abi.functions()
530                .find(|&abi_func| abi_func.selector() == func.selector())
531                .wrap_err(format!("Function `{}` is not implemented in your script.", self.sig))?
532        } else {
533            let matching_functions =
534                abi.functions().filter(|func| func.name == self.sig).collect::<Vec<_>>();
535            match matching_functions.len() {
536                0 => eyre::bail!("Function `{}` not found in the ABI", self.sig),
537                1 => matching_functions[0],
538                2.. => eyre::bail!(
539                    "Multiple functions with the same name `{}` found in the ABI",
540                    self.sig
541                ),
542            }
543        };
544        let data = encode_function_args(func, &self.args)?;
545
546        Ok((func.clone(), data.into()))
547    }
548
549    /// Checks if the transaction is a deployment with either a size above the default contract size
550    /// limit or specified `code_size_limit`.
551    ///
552    /// If `self.broadcast` is enabled, it asks confirmation of the user. Otherwise, it just warns
553    /// the user.
554    fn check_contract_sizes<N: Network>(
555        &self,
556        size_limits: ContractSizeLimits,
557        result: &ScriptResult<N>,
558        known_contracts: &ContractsByArtifact,
559        create2_deployer: Address,
560    ) -> Result<()> {
561        // If disable-code-size-limit flag is enabled then skip the size check
562        if self.disable_code_size_limit {
563            return Ok(());
564        }
565
566        // (name, &init, &deployed)[]
567        let mut bytecodes: Vec<(String, &[u8], &[u8])> = vec![];
568
569        // From artifacts
570        for (artifact, contract) in known_contracts.iter() {
571            let Some(bytecode) = contract.bytecode() else { continue };
572            let Some(deployed_bytecode) = contract.deployed_bytecode() else { continue };
573            bytecodes.push((artifact.name.clone(), bytecode, deployed_bytecode));
574        }
575
576        // From traces
577        let create_nodes = result.traces.iter().flat_map(|(_, traces)| {
578            traces.nodes().iter().filter(|node| node.trace.kind.is_any_create())
579        });
580        let mut unknown_c = 0usize;
581        for node in create_nodes {
582            let init_code = &node.trace.data;
583            let deployed_code = &node.trace.output;
584            if !bytecodes.iter().any(|(_, b, _)| *b == init_code.as_ref()) {
585                bytecodes.push((format!("Unknown{unknown_c}"), init_code, deployed_code));
586                unknown_c += 1;
587            }
588        }
589
590        let mut prompt_user = false;
591        let max_size = size_limits.runtime;
592
593        for (data, to) in result.transactions.iter().flat_map(|txes| {
594            txes.iter().filter_map(|tx| {
595                tx.transaction
596                    .input()
597                    .filter(|data| data.len() > max_size)
598                    .map(|data| (data, tx.transaction.to()))
599            })
600        }) {
601            let mut offset = 0;
602
603            // Find if it's a CREATE or CREATE2. Otherwise, skip transaction.
604            if let Some(to) = to {
605                if to == create2_deployer {
606                    // Size of the salt prefix.
607                    offset = 32;
608                } else {
609                    continue;
610                }
611            }
612
613            // Find artifact with a deployment code same as the data.
614            if let Some((name, _, deployed_code)) =
615                bytecodes.iter().find(|(_, init_code, _)| *init_code == &data[offset..])
616            {
617                let deployment_size = deployed_code.len();
618
619                if deployment_size > max_size {
620                    prompt_user = self.should_broadcast();
621                    sh_err!(
622                        "`{name}` is above the contract size limit ({deployment_size} > {max_size})."
623                    )?;
624                }
625            }
626        }
627
628        // Only prompt if we're broadcasting and we've not disabled interactivity.
629        if prompt_user
630            && !self.non_interactive
631            && !Confirm::new().with_prompt("Do you wish to continue?".to_string()).interact()?
632        {
633            eyre::bail!("User canceled the script.");
634        }
635
636        Ok(())
637    }
638
639    /// We only broadcast transactions if --broadcast, --resume, or --verify was passed.
640    const fn should_broadcast(&self) -> bool {
641        self.broadcast || self.resume || self.verify
642    }
643}
644
645impl Provider for ScriptArgs {
646    fn metadata(&self) -> Metadata {
647        Metadata::named("Script Args Provider")
648    }
649
650    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
651        let mut dict = Dict::default();
652
653        if let Some(etherscan_api_key) =
654            self.etherscan_api_key.as_ref().filter(|s| !s.trim().is_empty())
655        {
656            dict.insert(
657                "etherscan_api_key".to_string(),
658                figment::value::Value::from(etherscan_api_key.clone()),
659            );
660        }
661
662        if let Some(timeout) = self.timeout {
663            dict.insert("transaction_timeout".to_string(), timeout.into());
664        }
665
666        Ok(Map::from([(Config::selected_profile(), dict)]))
667    }
668}
669
670#[derive(Serialize, Clone)]
671#[serde(bound = "")]
672pub struct ScriptResult<N: Network> {
673    pub success: bool,
674    #[serde(rename = "raw_logs")]
675    pub logs: Vec<Log>,
676    pub traces: Traces,
677    pub gas_used: u64,
678    pub labeled_addresses: AddressHashMap<String>,
679    #[serde(skip)]
680    pub transactions: Option<BroadcastableTransactions<N>>,
681    pub returned: Bytes,
682    #[serde(skip)]
683    pub exit_reason: Option<InstructionResult>,
684    pub address: Option<Address>,
685    #[serde(skip)]
686    pub breakpoints: Breakpoints,
687}
688
689impl<N: Network> Default for ScriptResult<N> {
690    fn default() -> Self {
691        Self {
692            success: Default::default(),
693            logs: Default::default(),
694            traces: Default::default(),
695            gas_used: Default::default(),
696            labeled_addresses: Default::default(),
697            transactions: Default::default(),
698            returned: Default::default(),
699            exit_reason: Default::default(),
700            address: Default::default(),
701            breakpoints: Default::default(),
702        }
703    }
704}
705
706impl<N: Network> ScriptResult<N> {
707    pub fn get_created_contracts(
708        &self,
709        known_contracts: &ContractsByArtifact,
710    ) -> Vec<AdditionalContract> {
711        self.traces
712            .iter()
713            .flat_map(|(_, traces)| {
714                traces.nodes().iter().filter_map(|node| {
715                    if node.trace.kind.is_any_create() {
716                        let init_code = node.trace.data.clone();
717                        let contract_name = known_contracts
718                            .find_by_creation_code(init_code.as_ref())
719                            .map(|artifact| artifact.0.name.clone());
720                        return Some(AdditionalContract {
721                            call_kind: node.trace.kind,
722                            address: node.trace.address,
723                            contract_name,
724                            init_code,
725                        });
726                    }
727                    None
728                })
729            })
730            .collect()
731    }
732}
733
734#[derive(Serialize)]
735#[serde(bound = "")]
736struct JsonResult<'a, N: Network> {
737    logs: Vec<String>,
738    returns: &'a HashMap<String, NestedValue>,
739    #[serde(flatten)]
740    result: &'a ScriptResult<N>,
741}
742
743#[derive(Clone, Debug)]
744pub struct ScriptConfig<FEN: FoundryEvmNetwork> {
745    pub config: Config,
746    pub evm_opts: EvmOpts,
747    pub sender_nonce: u64,
748    /// Maps a rpc url to a backend
749    pub backends: HashMap<String, Backend<FEN>>,
750    /// Whether to batch all broadcast transactions into a single Tempo batch transaction.
751    pub batch: bool,
752    /// Tempo transaction options applied to broadcast transactions.
753    pub tempo: TempoOpts,
754}
755
756impl<FEN: FoundryEvmNetwork> ScriptConfig<FEN> {
757    pub async fn new(
758        config: Config,
759        evm_opts: EvmOpts,
760        batch: bool,
761        tempo: TempoOpts,
762    ) -> Result<Self> {
763        let sender_nonce = if let Some(fork_url) = evm_opts.fork_url.as_ref() {
764            next_nonce(evm_opts.sender, fork_url, evm_opts.fork_block_number).await?
765        } else {
766            // dapptools compatibility
767            1
768        };
769
770        Ok(Self { config, evm_opts, sender_nonce, backends: HashMap::default(), batch, tempo })
771    }
772
773    pub async fn update_sender(&mut self, sender: Address) -> Result<()> {
774        self.sender_nonce = if let Some(fork_url) = self.evm_opts.fork_url.as_ref() {
775            next_nonce(sender, fork_url, None).await?
776        } else {
777            // dapptools compatibility
778            1
779        };
780        self.evm_opts.sender = sender;
781        Ok(())
782    }
783
784    pub(crate) async fn update_tempo_session_sender(
785        &mut self,
786        wallets: &MultiWalletOpts,
787        expected_sender: Option<Address>,
788    ) -> Result<()> {
789        if let Some(sender) =
790            self.tempo.session_sender_for_multi_wallet(wallets, expected_sender)?
791        {
792            self.update_sender(sender).await?;
793        }
794        Ok(())
795    }
796
797    async fn get_runner(&mut self) -> Result<ScriptRunner<FEN>> {
798        self._get_runner(None, false).await
799    }
800
801    async fn get_runner_with_cheatcodes(
802        &mut self,
803        known_contracts: ContractsByArtifact,
804        script_wallets: Wallets,
805        debug: bool,
806        target: ArtifactId,
807    ) -> Result<ScriptRunner<FEN>> {
808        self._get_runner(Some((known_contracts, script_wallets, target)), debug).await
809    }
810
811    async fn _get_runner(
812        &mut self,
813        cheats_data: Option<(ContractsByArtifact, Wallets, ArtifactId)>,
814        debug: bool,
815    ) -> Result<ScriptRunner<FEN>> {
816        trace!("preparing script runner");
817        let (evm_env, mut tx_env, fork_block) = self.evm_opts.env::<_, _, TxEnvFor<FEN>>().await?;
818
819        let db = if let Some(fork_url) = self.evm_opts.fork_url.as_ref() {
820            match self.backends.get(fork_url) {
821                Some(db) => db.clone(),
822                None => {
823                    let fork =
824                        self.evm_opts.get_fork(&self.config, evm_env.cfg_env.chain_id, fork_block);
825                    let backend = Backend::spawn(fork)?;
826                    self.backends.insert(fork_url.clone(), backend.clone());
827                    backend
828                }
829            }
830        } else {
831            // It's only really `None`, when we don't pass any `--fork-url`. And if so, there is
832            // no need to cache it, since there won't be any onchain simulation that we'd need
833            // to cache the backend for.
834            Backend::spawn(None)?
835        };
836
837        // We need to enable tracing to decode contract names: local or external.
838        let mut builder = ExecutorBuilder::default()
839            .inspectors(|stack| {
840                stack
841                    .logs(self.config.live_logs)
842                    .trace_mode(if debug { TraceMode::Debug } else { TraceMode::Call })
843                    .networks(self.evm_opts.networks)
844                    .create2_deployer(self.evm_opts.create2_deployer)
845            })
846            .spec_id(self.config.evm_spec_id())
847            .gas_limit(self.evm_opts.gas_limit())
848            .legacy_assertions(self.config.legacy_assertions);
849
850        if let Some((known_contracts, script_wallets, target)) = cheats_data {
851            builder = builder.inspectors(|stack| {
852                stack
853                    .cheatcodes(
854                        CheatsConfig::new(
855                            &self.config,
856                            self.evm_opts.clone(),
857                            Some(known_contracts),
858                            Some(target),
859                            self.tempo.fee_token,
860                            self.batch,
861                        )
862                        .into(),
863                    )
864                    .wallets(script_wallets)
865                    .enable_isolation(self.evm_opts.isolate)
866            });
867        }
868
869        // Propagate fee token to the transaction environment so that internal EVM calls
870        // (e.g. script deployment, setUp) use the correct fee token for Tempo networks.
871        tx_env.set_fee_token(self.tempo.fee_token);
872
873        Ok(ScriptRunner::new(builder.build(evm_env, tx_env, db), self.evm_opts.clone()))
874    }
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use alloy_network::Ethereum;
881    use alloy_primitives::{B256, address};
882    use foundry_cli::opts::TEMPO_SESSION_ID_ENV;
883    use foundry_common::tempo::{
884        KeyType, SessionEntry, SessionKeyMaterial, SessionStatus, TEMPO_HOME_ENV,
885        upsert_session_entry,
886    };
887    use foundry_config::UnresolvedEnvVarError;
888    use std::{fs, sync::LazyLock};
889    use tempfile::tempdir;
890    use tokio::sync::{Mutex, MutexGuard};
891
892    const SESSION_PRIVATE_KEY: &str =
893        "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0";
894    const SESSION_ID_HEX: &str =
895        "0x1111111111111111111111111111111111111111111111111111111111111111";
896    const SESSION_ROOT_ADDRESS: &str = "0x1111111111111111111111111111111111111111";
897    static TEMPO_HOME_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
898
899    fn active_session_entry(
900        session_id: B256,
901        root_account: Address,
902        chain_id: u64,
903    ) -> SessionEntry {
904        let key = foundry_wallets::utils::create_private_key_signer(SESSION_PRIVATE_KEY).unwrap();
905        SessionEntry {
906            session_id,
907            root_account,
908            chain_id,
909            key_address: key.address(),
910            expiry: u64::MAX,
911            scope: None,
912            limits: None,
913            status: SessionStatus::Active,
914            key: Some(SessionKeyMaterial {
915                key_type: KeyType::Secp256k1,
916                key: SESSION_PRIVATE_KEY.to_string(),
917                key_authorization: None,
918            }),
919        }
920    }
921
922    struct TempoHomeGuard {
923        _guard: MutexGuard<'static, ()>,
924    }
925
926    impl TempoHomeGuard {
927        async fn set(path: &std::path::Path) -> Self {
928            let guard = TEMPO_HOME_LOCK.lock().await;
929            // SAFETY: test-only environment override for Tempo local state.
930            unsafe {
931                std::env::remove_var(TEMPO_SESSION_ID_ENV);
932                std::env::set_var(TEMPO_HOME_ENV, path);
933            }
934            Self { _guard: guard }
935        }
936    }
937
938    impl Drop for TempoHomeGuard {
939        fn drop(&mut self) {
940            // SAFETY: restore process environment after the critical section.
941            unsafe {
942                std::env::remove_var(TEMPO_HOME_ENV);
943                std::env::remove_var(TEMPO_SESSION_ID_ENV);
944            }
945        }
946    }
947
948    fn session_root() -> Address {
949        SESSION_ROOT_ADDRESS.parse().unwrap()
950    }
951
952    #[test]
953    fn can_parse_sig() {
954        let sig = "0x522bb704000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfFFb92266";
955        let args = ScriptArgs::parse_from(["foundry-cli", "Contract.sol", "--sig", sig]);
956        assert_eq!(args.sig, sig);
957    }
958
959    #[test]
960    fn can_parse_shared_tempo_opts() {
961        let args = ScriptArgs::parse_from([
962            "foundry-cli",
963            "Contract.sol",
964            "--tempo.fee-token",
965            "1",
966            "--tempo.expires",
967            "10",
968        ]);
969
970        assert_eq!(
971            args.tempo.fee_token,
972            Some(address!("0x20C0000000000000000000000000000000000001"))
973        );
974        assert_eq!(args.tempo.expires, Some(10));
975    }
976
977    #[test]
978    fn can_parse_sponsor_tempo_opts() {
979        let args = ScriptArgs::parse_from([
980            "foundry-cli",
981            "Contract.sol",
982            "--tempo.sponsor",
983            SESSION_ROOT_ADDRESS,
984            "--tempo.sponsor-signer",
985            "env://TEMPO_SPONSOR_PK",
986        ]);
987
988        assert_eq!(args.tempo.sponsor, Some(session_root()));
989        assert_eq!(args.tempo.sponsor_signer.as_deref(), Some("env://TEMPO_SPONSOR_PK"));
990    }
991
992    #[test]
993    fn can_parse_full_tempo_opts() {
994        let args =
995            ScriptArgs::parse_from(["foundry-cli", "Contract.sol", "--tempo.nonce-key", "1"]);
996
997        assert_eq!(args.tempo.nonce_key, Some(U256::from(1)));
998    }
999
1000    #[test]
1001    fn can_parse_tempo_session_opt() {
1002        let args = ScriptArgs::parse_from([
1003            "foundry-cli",
1004            "Contract.sol",
1005            "--tempo.session",
1006            SESSION_ID_HEX,
1007        ]);
1008
1009        assert_eq!(args.tempo.session, Some(B256::from([0x11; 32])),);
1010    }
1011
1012    #[tokio::test]
1013    async fn tempo_session_sets_script_sender_to_root_account() {
1014        let temp = tempdir().unwrap();
1015        let session_id = B256::from([0x22; 32]);
1016        let root = session_root();
1017        let chain_id = foundry_common::DEV_CHAIN_ID;
1018
1019        let _guard = TempoHomeGuard::set(temp.path()).await;
1020        upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap();
1021
1022        let args = ScriptArgs::parse_from([
1023            "foundry-cli",
1024            "Contract.sol",
1025            "--tempo.session",
1026            &format!("{session_id:?}"),
1027        ]);
1028        let evm_opts = EvmOpts {
1029            networks: NetworkConfigs::with_tempo(),
1030            env: foundry_evm::opts::Env { chain_id: Some(chain_id), ..Default::default() },
1031            ..Default::default()
1032        };
1033
1034        let state = args.preprocess::<TempoEvmNetwork>(Config::default(), evm_opts).await.unwrap();
1035        assert_eq!(state.script_config.evm_opts.sender, root);
1036    }
1037
1038    #[tokio::test]
1039    async fn tempo_session_resume_multi_defers_session_sender_until_reexecution() {
1040        let temp = tempdir().unwrap();
1041        let session_id = B256::from([0x55; 32]);
1042        let root = session_root();
1043        let chain_id = 4217;
1044
1045        let _guard = TempoHomeGuard::set(temp.path()).await;
1046        upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap();
1047
1048        let args = ScriptArgs::parse_from([
1049            "foundry-cli",
1050            "Contract.sol",
1051            "--resume",
1052            "--multi",
1053            "--tempo.session",
1054            &format!("{session_id:?}"),
1055        ]);
1056        let evm_opts = EvmOpts { networks: NetworkConfigs::with_tempo(), ..Default::default() };
1057
1058        let state = args.preprocess::<TempoEvmNetwork>(Config::default(), evm_opts).await.unwrap();
1059        assert_ne!(state.script_config.evm_opts.sender, root);
1060    }
1061
1062    #[tokio::test]
1063    async fn tempo_session_resume_defers_session_sender_until_reexecution() {
1064        let temp = tempdir().unwrap();
1065        let session_id = B256::from([0x77; 32]);
1066        let root = session_root();
1067        let chain_id = 4217;
1068
1069        let _guard = TempoHomeGuard::set(temp.path()).await;
1070        upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap();
1071
1072        let args = ScriptArgs::parse_from([
1073            "foundry-cli",
1074            "Contract.sol",
1075            "--resume",
1076            "--tempo.session",
1077            &format!("{session_id:?}"),
1078        ]);
1079        let evm_opts = EvmOpts { networks: NetworkConfigs::with_tempo(), ..Default::default() };
1080
1081        let state = args.preprocess::<TempoEvmNetwork>(Config::default(), evm_opts).await.unwrap();
1082        assert_ne!(state.script_config.evm_opts.sender, root);
1083    }
1084
1085    #[tokio::test]
1086    async fn tempo_session_non_resume_multi_sets_sender_without_chain_validation() {
1087        let temp = tempdir().unwrap();
1088        let session_id = B256::from([0x66; 32]);
1089        let root = session_root();
1090        let chain_id = 4217;
1091
1092        let _guard = TempoHomeGuard::set(temp.path()).await;
1093        upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap();
1094
1095        let args = ScriptArgs::parse_from([
1096            "foundry-cli",
1097            "Contract.sol",
1098            "--multi",
1099            "--tempo.session",
1100            &format!("{session_id:?}"),
1101        ]);
1102        let evm_opts = EvmOpts { networks: NetworkConfigs::with_tempo(), ..Default::default() };
1103
1104        let state = args.preprocess::<TempoEvmNetwork>(Config::default(), evm_opts).await.unwrap();
1105        assert_eq!(state.script_config.evm_opts.sender, root);
1106    }
1107
1108    #[tokio::test]
1109    async fn tempo_session_initial_broadcast_sets_sender_without_chain_validation() {
1110        let temp = tempdir().unwrap();
1111        let session_id = B256::from([0x88; 32]);
1112        let root = session_root();
1113        let chain_id = 4217;
1114
1115        let _guard = TempoHomeGuard::set(temp.path()).await;
1116        upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap();
1117
1118        let args = ScriptArgs::parse_from([
1119            "foundry-cli",
1120            "Contract.sol",
1121            "--broadcast",
1122            "--tempo.session",
1123            &format!("{session_id:?}"),
1124        ]);
1125        let evm_opts = EvmOpts { networks: NetworkConfigs::with_tempo(), ..Default::default() };
1126
1127        let state = args.preprocess::<TempoEvmNetwork>(Config::default(), evm_opts).await.unwrap();
1128        assert_eq!(state.script_config.evm_opts.sender, root);
1129    }
1130
1131    #[tokio::test]
1132    async fn tempo_session_env_selects_tempo_network() {
1133        let temp = tempdir().unwrap();
1134        let _guard = TempoHomeGuard::set(temp.path()).await;
1135        let session_id = B256::from([0x44; 32]);
1136        // SAFETY: serialized by TempoHomeGuard.
1137        unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, format!("{session_id:?}")) };
1138
1139        let args = ScriptArgs::parse_from(["foundry-cli", "Contract.sol"]);
1140        let (_, evm_opts) = args.resolved_evm_opts().await.unwrap();
1141
1142        assert!(evm_opts.networks.is_tempo());
1143    }
1144
1145    #[tokio::test]
1146    async fn tempo_session_rejects_explicit_script_wallet_signer() {
1147        let temp = tempdir().unwrap();
1148        let session_id = B256::from([0x33; 32]);
1149        let root = session_root();
1150        let chain_id = foundry_common::DEV_CHAIN_ID;
1151
1152        let _guard = TempoHomeGuard::set(temp.path()).await;
1153        upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap();
1154
1155        let args = ScriptArgs::parse_from([
1156            "foundry-cli",
1157            "Contract.sol",
1158            "--tempo.session",
1159            &format!("{session_id:?}"),
1160            "--private-key",
1161            SESSION_PRIVATE_KEY,
1162        ]);
1163        let evm_opts = EvmOpts {
1164            networks: NetworkConfigs::with_tempo(),
1165            env: foundry_evm::opts::Env { chain_id: Some(chain_id), ..Default::default() },
1166            ..Default::default()
1167        };
1168
1169        let err = match args.preprocess::<TempoEvmNetwork>(Config::default(), evm_opts).await {
1170            Ok(_) => panic!("expected --tempo.session with --private-key to fail"),
1171            Err(err) => err,
1172        };
1173        assert!(err.to_string().contains("explicit wallet signer"), "{err}");
1174    }
1175
1176    #[test]
1177    fn can_parse_unlocked() {
1178        let args = ScriptArgs::parse_from([
1179            "foundry-cli",
1180            "Contract.sol",
1181            "--sender",
1182            "0x4e59b44847b379578588920ca78fbf26c0b4956c",
1183            "--unlocked",
1184        ]);
1185        assert!(args.unlocked);
1186
1187        let key = U256::ZERO;
1188        let args = ScriptArgs::try_parse_from([
1189            "foundry-cli",
1190            "Contract.sol",
1191            "--sender",
1192            "0x4e59b44847b379578588920ca78fbf26c0b4956c",
1193            "--unlocked",
1194            "--private-key",
1195            &key.to_string(),
1196        ]);
1197        assert!(args.is_err());
1198    }
1199
1200    #[test]
1201    fn can_merge_script_config() {
1202        let args = ScriptArgs::parse_from([
1203            "foundry-cli",
1204            "Contract.sol",
1205            "--etherscan-api-key",
1206            "goerli",
1207        ]);
1208        let config = args.load_config().unwrap();
1209        assert_eq!(config.etherscan_api_key, Some("goerli".to_string()));
1210    }
1211
1212    #[test]
1213    fn can_disable_code_size_limit() {
1214        let args =
1215            ScriptArgs::parse_from(["foundry-cli", "Contract.sol", "--disable-code-size-limit"]);
1216        assert!(args.disable_code_size_limit);
1217
1218        let result = ScriptResult::<Ethereum>::default();
1219        let contracts = ContractsByArtifact::default();
1220        let create = Address::ZERO;
1221        assert!(
1222            args.check_contract_sizes(ContractSizeLimits::default(), &result, &contracts, create)
1223                .is_ok()
1224        );
1225    }
1226
1227    #[test]
1228    fn can_parse_verifier_url() {
1229        let args = ScriptArgs::parse_from([
1230            "foundry-cli",
1231            "script",
1232            "script/Test.s.sol:TestScript",
1233            "--fork-url",
1234            "http://localhost:8545",
1235            "--verifier-url",
1236            "http://localhost:3000/api/verify",
1237            "--etherscan-api-key",
1238            "blacksmith",
1239            "--broadcast",
1240            "--verify",
1241            "-vvvvv",
1242        ]);
1243        assert_eq!(
1244            args.verifier.verifier_url,
1245            Some("http://localhost:3000/api/verify".to_string())
1246        );
1247    }
1248
1249    #[test]
1250    fn can_extract_code_size_limit() {
1251        let args = ScriptArgs::parse_from([
1252            "foundry-cli",
1253            "script",
1254            "script/Test.s.sol:TestScript",
1255            "--fork-url",
1256            "http://localhost:8545",
1257            "--broadcast",
1258            "--code-size-limit",
1259            "50000",
1260        ]);
1261        assert_eq!(args.evm.env.code_size_limit, Some(50000));
1262    }
1263
1264    #[test]
1265    fn can_extract_script_etherscan_key() {
1266        let temp = tempdir().unwrap();
1267        let root = temp.path();
1268
1269        let config = r#"
1270                [profile.default]
1271                etherscan_api_key = "amoy"
1272
1273                [etherscan]
1274                amoy = { key = "https://etherscan-amoy.com/" }
1275            "#;
1276
1277        let toml_file = root.join(Config::FILE_NAME);
1278        fs::write(toml_file, config).unwrap();
1279        let args = ScriptArgs::parse_from([
1280            "foundry-cli",
1281            "Contract.sol",
1282            "--etherscan-api-key",
1283            "amoy",
1284            "--root",
1285            root.as_os_str().to_str().unwrap(),
1286        ]);
1287
1288        let config = args.load_config().unwrap();
1289        let amoy = config.get_etherscan_api_key(Some(NamedChain::PolygonAmoy.into()));
1290        assert_eq!(amoy, Some("https://etherscan-amoy.com/".to_string()));
1291    }
1292
1293    #[test]
1294    fn can_extract_script_rpc_alias() {
1295        let temp = tempdir().unwrap();
1296        let root = temp.path();
1297
1298        let config = r#"
1299                [profile.default]
1300
1301                [rpc_endpoints]
1302                polygonAmoy = "https://polygon-amoy.g.alchemy.com/v2/${_CAN_EXTRACT_RPC_ALIAS}"
1303            "#;
1304
1305        let toml_file = root.join(Config::FILE_NAME);
1306        fs::write(toml_file, config).unwrap();
1307        let args = ScriptArgs::parse_from([
1308            "foundry-cli",
1309            "DeployV1",
1310            "--rpc-url",
1311            "polygonAmoy",
1312            "--root",
1313            root.as_os_str().to_str().unwrap(),
1314        ]);
1315
1316        let err = args.load_config_and_evm_opts().unwrap_err();
1317
1318        assert!(err.downcast::<UnresolvedEnvVarError>().is_ok());
1319
1320        unsafe {
1321            std::env::set_var("_CAN_EXTRACT_RPC_ALIAS", "123456");
1322        }
1323        let (config, evm_opts) = args.load_config_and_evm_opts().unwrap();
1324        assert_eq!(config.eth_rpc_url, Some("polygonAmoy".to_string()));
1325        assert_eq!(
1326            evm_opts.fork_url,
1327            Some("https://polygon-amoy.g.alchemy.com/v2/123456".to_string())
1328        );
1329    }
1330
1331    #[test]
1332    fn can_extract_script_rpc_and_etherscan_alias() {
1333        let temp = tempdir().unwrap();
1334        let root = temp.path();
1335
1336        let config = r#"
1337            [profile.default]
1338
1339            [rpc_endpoints]
1340            amoy = "https://polygon-amoy.g.alchemy.com/v2/${_EXTRACT_RPC_ALIAS}"
1341
1342            [etherscan]
1343            amoy = { key = "${_ETHERSCAN_API_KEY}", chain = 80002, url = "https://amoy.polygonscan.com/" }
1344        "#;
1345
1346        let toml_file = root.join(Config::FILE_NAME);
1347        fs::write(toml_file, config).unwrap();
1348        let args = ScriptArgs::parse_from([
1349            "foundry-cli",
1350            "DeployV1",
1351            "--rpc-url",
1352            "amoy",
1353            "--etherscan-api-key",
1354            "amoy",
1355            "--root",
1356            root.as_os_str().to_str().unwrap(),
1357        ]);
1358        let err = args.load_config_and_evm_opts().unwrap_err();
1359
1360        assert!(err.downcast::<UnresolvedEnvVarError>().is_ok());
1361
1362        unsafe {
1363            std::env::set_var("_EXTRACT_RPC_ALIAS", "123456");
1364        }
1365        unsafe {
1366            std::env::set_var("_ETHERSCAN_API_KEY", "etherscan_api_key");
1367        }
1368        let (config, evm_opts) = args.load_config_and_evm_opts().unwrap();
1369        assert_eq!(config.eth_rpc_url, Some("amoy".to_string()));
1370        assert_eq!(
1371            evm_opts.fork_url,
1372            Some("https://polygon-amoy.g.alchemy.com/v2/123456".to_string())
1373        );
1374        let etherscan = config.get_etherscan_api_key(Some(80002u64.into()));
1375        assert_eq!(etherscan, Some("etherscan_api_key".to_string()));
1376        let etherscan = config.get_etherscan_api_key(None);
1377        assert_eq!(etherscan, Some("etherscan_api_key".to_string()));
1378    }
1379
1380    #[test]
1381    fn can_extract_script_rpc_and_sole_etherscan_alias() {
1382        let temp = tempdir().unwrap();
1383        let root = temp.path();
1384
1385        let config = r#"
1386                [profile.default]
1387
1388               [rpc_endpoints]
1389                amoy = "https://polygon-amoy.g.alchemy.com/v2/${_SOLE_EXTRACT_RPC_ALIAS}"
1390
1391                [etherscan]
1392                amoy = { key = "${_SOLE_ETHERSCAN_API_KEY}" }
1393            "#;
1394
1395        let toml_file = root.join(Config::FILE_NAME);
1396        fs::write(toml_file, config).unwrap();
1397        let args = ScriptArgs::parse_from([
1398            "foundry-cli",
1399            "DeployV1",
1400            "--rpc-url",
1401            "amoy",
1402            "--root",
1403            root.as_os_str().to_str().unwrap(),
1404        ]);
1405        let err = args.load_config_and_evm_opts().unwrap_err();
1406
1407        assert!(err.downcast::<UnresolvedEnvVarError>().is_ok());
1408
1409        unsafe {
1410            std::env::set_var("_SOLE_EXTRACT_RPC_ALIAS", "123456");
1411        }
1412        unsafe {
1413            std::env::set_var("_SOLE_ETHERSCAN_API_KEY", "etherscan_api_key");
1414        }
1415        let (config, evm_opts) = args.load_config_and_evm_opts().unwrap();
1416        assert_eq!(
1417            evm_opts.fork_url,
1418            Some("https://polygon-amoy.g.alchemy.com/v2/123456".to_string())
1419        );
1420        let etherscan = config.get_etherscan_api_key(Some(80002u64.into()));
1421        assert_eq!(etherscan, Some("etherscan_api_key".to_string()));
1422        let etherscan = config.get_etherscan_api_key(None);
1423        assert_eq!(etherscan, Some("etherscan_api_key".to_string()));
1424    }
1425
1426    // <https://github.com/foundry-rs/foundry/issues/5923>
1427    #[test]
1428    fn test_5923() {
1429        let args =
1430            ScriptArgs::parse_from(["foundry-cli", "DeployV1", "--priority-gas-price", "100"]);
1431        assert!(args.priority_gas_price.is_some());
1432    }
1433
1434    // <https://github.com/foundry-rs/foundry/issues/5910>
1435    #[test]
1436    fn test_5910() {
1437        let args = ScriptArgs::parse_from([
1438            "foundry-cli",
1439            "--broadcast",
1440            "--with-gas-price",
1441            "0",
1442            "SolveTutorial",
1443        ]);
1444        assert!(args.with_gas_price.unwrap().is_zero());
1445    }
1446
1447    #[test]
1448    fn test_priority_gas_price_cannot_exceed_gas_price() {
1449        let args = ScriptArgs::parse_from([
1450            "foundry-cli",
1451            "--broadcast",
1452            "--with-gas-price",
1453            "100",
1454            "--priority-gas-price",
1455            "200",
1456            "Script",
1457        ]);
1458        // priority (200) > max_fee (100) — broadcast should reject this at runtime
1459        assert!(args.priority_gas_price.unwrap() > args.with_gas_price.unwrap());
1460    }
1461}