Skip to main content

chisel/
dispatcher.rs

1//! Dispatcher
2//!
3//! This module contains the `ChiselDispatcher` struct, which handles the dispatching
4//! of both builtin commands and Solidity snippets.
5
6use crate::{
7    executor::InspectResult,
8    prelude::{ChiselCommand, ChiselResult, ChiselSession, SessionSourceConfig, SolidityHelper},
9    source::{InvocationRpc, SessionSource},
10};
11use alloy_primitives::{Address, hex};
12use eyre::{Context, Result};
13use forge_fmt::FormatterConfig;
14use foundry_cli::utils::fetch_abi_from_etherscan;
15use foundry_config::{Chain, Config, RpcEndpointUrl};
16use foundry_evm::{
17    core::evm::{EthEvmNetwork, FoundryEvmNetwork},
18    decode::decode_console_logs,
19    executors::ExecutorBuilder,
20    traces::{
21        CallTraceDecoder, CallTraceDecoderBuilder, TraceKind, decode_trace_arena,
22        identifier::{SignaturesIdentifier, TraceIdentifiers},
23        render_trace_arena,
24    },
25};
26use foundry_evm_networks::{NetworkConfigs, NetworkVariant};
27use reqwest::Url;
28use solar::{
29    parse::lexer::token::{RawLiteralKind, RawTokenKind},
30    sema::ast::Base,
31};
32use std::{
33    borrow::Cow,
34    io::Write,
35    ops::ControlFlow,
36    path::{Path, PathBuf},
37    process::Command,
38};
39use tempfile::Builder;
40use yansi::Paint;
41
42/// Prompt arrow character.
43pub const PROMPT_ARROW: char = '➜';
44/// Prompt arrow string.
45pub const PROMPT_ARROW_STR: &str = "➜";
46const DEFAULT_PROMPT: &str = "➜ ";
47
48/// Command leader character
49pub const COMMAND_LEADER: char = '!';
50/// Chisel character
51pub const CHISEL_CHAR: &str = "⚒️";
52
53/// Chisel input dispatcher
54#[derive(Debug)]
55pub struct ChiselDispatcher<FEN: FoundryEvmNetwork> {
56    pub session: ChiselSession<FEN>,
57    pub helper: SolidityHelper,
58    last_result: Option<String>,
59    /// RPC settings supplied to this invocation, independent of previously loaded sessions.
60    invocation_rpc: InvocationRpc,
61}
62
63/// Helper function that formats solidity source with the given [FormatterConfig]
64pub fn format_source(source: &str, config: FormatterConfig) -> eyre::Result<String> {
65    let formatted = forge_fmt::format(source, config).into_result()?;
66    Ok(formatted)
67}
68
69impl<FEN: FoundryEvmNetwork> ChiselDispatcher<FEN> {
70    /// Associated public function to create a new Dispatcher instance
71    pub fn new(config: SessionSourceConfig<FEN>) -> eyre::Result<Self> {
72        let invocation_rpc = InvocationRpc::capture(&config.foundry_config, &config.evm_opts);
73        let session = ChiselSession::new(config)?;
74        Ok(Self { session, helper: Default::default(), last_result: None, invocation_rpc })
75    }
76
77    /// Returns the optional ID of the current session.
78    pub fn id(&self) -> Option<&str> {
79        self.session.id.as_deref()
80    }
81
82    /// Returns the [`SessionSource`].
83    pub const fn source(&self) -> &SessionSource<FEN> {
84        &self.session.source
85    }
86
87    /// Returns the [`SessionSource`].
88    pub const fn source_mut(&mut self) -> &mut SessionSource<FEN> {
89        &mut self.session.source
90    }
91
92    fn format_source(&self) -> eyre::Result<String> {
93        format_source(
94            &self.source().to_repl_source(),
95            self.source().config.foundry_config.fmt.clone(),
96        )
97    }
98
99    /// Returns the prompt based on the current status of the Dispatcher
100    pub fn get_prompt(&self) -> Cow<'static, str> {
101        match self.session.id.as_deref() {
102            // `(ID: {id}) ➜ `
103            Some(id) => {
104                let mut prompt = String::with_capacity(DEFAULT_PROMPT.len() + id.len() + 7);
105                prompt.push_str("(ID: ");
106                prompt.push_str(id);
107                prompt.push_str(") ");
108                prompt.push_str(DEFAULT_PROMPT);
109                Cow::Owned(prompt)
110            }
111            // `➜ `
112            None => Cow::Borrowed(DEFAULT_PROMPT),
113        }
114    }
115
116    /// Dispatches an input as a command via [Self::dispatch_command] or as a Solidity snippet.
117    pub async fn dispatch(&mut self, input: &str) -> Result<ControlFlow<()>> {
118        if let Some(command) = input.strip_prefix(COMMAND_LEADER) {
119            return match ChiselCommand::parse(command) {
120                Ok(cmd) => self.dispatch_command(cmd).await,
121                Err(e) => {
122                    eyre::bail!("unrecognized command: {e}");
123                }
124            };
125        }
126
127        self.dispatch_solidity(input).await
128    }
129
130    /// Dispatches an input as Solidity without interpreting Chisel commands.
131    pub(crate) async fn dispatch_solidity(&mut self, mut input: &str) -> Result<ControlFlow<()>> {
132        input = input.trim();
133        let (only_trivia, new_input) = preprocess(input, self.last_result.as_deref())?;
134        input = &*new_input;
135
136        let source = self.source_mut();
137
138        // If the input is a comment, add it to the run code so we avoid running with empty input
139        if only_trivia {
140            debug!(?input, "matched trivia");
141            if !input.is_empty() {
142                source.add_run_code(input);
143            }
144            return Ok(ControlFlow::Continue(()));
145        }
146
147        // Create new source with exact input appended and parse
148        let (new_source, do_execute) = source.clone_with_new_line(input.to_string())?;
149
150        let InspectResult {
151            control_flow,
152            formatted_output,
153            last_result,
154            clear_last_result,
155            replay_input,
156        } = source.inspect(input).await?;
157        let (new_source, do_execute) = if let Some(input) = replay_input {
158            source.clone_with_new_line(input)?
159        } else {
160            (new_source, do_execute)
161        };
162        if clear_last_result {
163            self.last_result = None;
164        } else if let Some(last_result) = last_result {
165            self.last_result = Some(last_result);
166        }
167        if let Some(res) = &formatted_output {
168            let _ = sh_println!("{res}");
169        }
170        if control_flow.is_break() {
171            debug!(%input, ?formatted_output, "inspect success");
172            return Ok(ControlFlow::Continue(()));
173        }
174
175        if do_execute {
176            self.execute_and_replace(new_source).await?;
177        } else {
178            let out = new_source.build()?;
179            debug!(%input, ?out, "skipped execute and rebuild source");
180            *self.source_mut() = new_source;
181        }
182        Ok(ControlFlow::Continue(()))
183    }
184
185    /// Decodes traces in the given [`ChiselResult`].
186    // TODO: Add `known_contracts` back in.
187    pub async fn decode_traces(
188        session_config: &SessionSourceConfig<FEN>,
189        result: &mut ChiselResult,
190        // known_contracts: &ContractsByArtifact,
191    ) -> eyre::Result<CallTraceDecoder> {
192        let chain_id = session_config.source_chain_id.map(Chain::from);
193        let resolved_hardfork = session_config.resolved_hardfork;
194
195        let builder = CallTraceDecoderBuilder::new()
196            .with_labels(result.labeled_addresses.clone())
197            .with_signature_identifier(SignaturesIdentifier::from_config(
198                &session_config.foundry_config,
199            )?)
200            .with_networks(session_config.foundry_config.networks)
201            .with_chain_id(chain_id.map(|c| c.id()))
202            .with_hardfork(resolved_hardfork);
203        let mut decoder = builder.build();
204
205        let mut identifier =
206            TraceIdentifiers::new().with_external(&session_config.foundry_config, chain_id)?;
207        if !identifier.is_empty() {
208            for (_, trace) in &mut result.traces {
209                decoder.identify(trace, &mut identifier);
210            }
211        }
212        Ok(decoder)
213    }
214
215    /// Display the gathered traces of a REPL execution.
216    pub async fn show_traces(
217        decoder: &CallTraceDecoder,
218        result: &mut ChiselResult,
219    ) -> eyre::Result<()> {
220        if result.traces.is_empty() {
221            return Ok(());
222        }
223
224        sh_println!("{}", "Traces:".green())?;
225        for (kind, trace) in &mut result.traces {
226            // Display all Setup + Execution traces.
227            if matches!(kind, TraceKind::Setup | TraceKind::Execution) {
228                decode_trace_arena(trace, decoder).await;
229                sh_println!("{}", render_trace_arena(trace))?;
230            }
231        }
232
233        Ok(())
234    }
235
236    async fn execute_and_replace(&mut self, mut new_source: SessionSource<FEN>) -> Result<()> {
237        let mut res = new_source.execute().await?;
238        let failed = !res.success;
239        if new_source.config.traces || failed {
240            if let Ok(decoder) = Self::decode_traces(&new_source.config, &mut res).await {
241                Self::show_traces(&decoder, &mut res).await?;
242
243                // Show console logs, if there are any
244                let decoded_logs = decode_console_logs(&res.logs);
245                if !decoded_logs.is_empty() {
246                    let _ = sh_println!("{}", "Logs:".green());
247                    for log in decoded_logs {
248                        let _ = sh_println!("  {log}");
249                    }
250                }
251            }
252
253            if failed {
254                // If the contract execution failed, continue on without
255                // updating the source.
256                eyre::bail!("Failed to execute edited contract!");
257            }
258        }
259
260        // the code could be compiled, save it
261        *self.source_mut() = new_source;
262
263        Ok(())
264    }
265}
266
267/// [`ChiselCommand`] implementations.
268impl<FEN: FoundryEvmNetwork> ChiselDispatcher<FEN> {
269    /// Dispatches a [`ChiselCommand`].
270    pub async fn dispatch_command(&mut self, cmd: ChiselCommand) -> Result<ControlFlow<()>> {
271        match cmd {
272            ChiselCommand::Quit => Ok(ControlFlow::Break(())),
273            cmd => self.dispatch_command_impl(cmd).await.map(ControlFlow::Continue),
274        }
275    }
276
277    async fn dispatch_command_impl(&mut self, cmd: ChiselCommand) -> Result<()> {
278        match cmd {
279            ChiselCommand::Help => self.show_help(),
280            ChiselCommand::Quit => unreachable!(),
281            ChiselCommand::Clear => self.clear_source(),
282            ChiselCommand::Save { id } => self.save_session(id),
283            ChiselCommand::Load { id } => self.load_session(&id),
284            ChiselCommand::ListSessions => self.list_sessions(),
285            ChiselCommand::Source => self.show_source(),
286            ChiselCommand::ClearCache => self.clear_cache(),
287            ChiselCommand::Fork { url } => self.set_fork(url).await,
288            ChiselCommand::Traces => self.toggle_traces(),
289            ChiselCommand::Calldata { data } => self.set_calldata(data.as_deref()),
290            ChiselCommand::MemDump => self.show_mem_dump().await,
291            ChiselCommand::StackDump => self.show_stack_dump().await,
292            ChiselCommand::Export => self.export(),
293            ChiselCommand::Fetch { addr, name } => self.fetch_interface(addr, name).await,
294            ChiselCommand::Exec { command, args } => self.exec_command(command, args),
295            ChiselCommand::Edit => self.edit_session().await,
296            ChiselCommand::RawStack { var } => self.show_raw_stack(var).await,
297        }
298    }
299
300    pub(crate) fn show_help(&self) -> Result<()> {
301        sh_println!("{}", ChiselCommand::format_help())
302    }
303
304    pub(crate) fn clear_source(&mut self) -> Result<()> {
305        self.source_mut().clear();
306        self.last_result = None;
307        sh_println!("Cleared session!")
308    }
309
310    pub(crate) fn save_session(&mut self, id: Option<String>) -> Result<()> {
311        let previous_id = self.session.id.clone();
312
313        // If a new name was supplied, overwrite the ID of the current session.
314        if let Some(id) = id {
315            self.session.id = Some(id);
316        }
317
318        let new_cache_file = match self.session.write() {
319            Ok(path) => path,
320            Err(error) => {
321                self.session.id = previous_id;
322                return Err(error);
323            }
324        };
325
326        if let (Some(previous_id), Some(current_id)) = (previous_id, self.session.id.as_deref())
327            && previous_id != current_id
328        {
329            let old_cache_file =
330                format!("{}chisel-{previous_id}.json", ChiselSession::<FEN>::cache_dir()?);
331            let same_cache_file = std::fs::canonicalize(&old_cache_file).ok()
332                == std::fs::canonicalize(&new_cache_file).ok();
333            if !same_cache_file {
334                ChiselSession::<FEN>::remove_cached_session(&previous_id)?;
335            }
336        }
337
338        sh_println!("Saved session to cache with ID = {}", self.session.id.as_ref().unwrap())
339    }
340
341    pub(crate) fn load_session(&mut self, id: &str) -> Result<()> {
342        // Try to save the current session before loading another.
343        // Don't save an empty session.
344        if !self.source().run_code.is_empty() {
345            self.session.write()?;
346            sh_println!("{}", "Saved current session!".green())?;
347        }
348
349        let executor_builder = self.session.source.config.executor_builder.clone();
350        let mut new_session = match id {
351            "latest" => ChiselSession::<FEN>::latest(executor_builder),
352            id => ChiselSession::<FEN>::load(id, executor_builder),
353        }
354        .wrap_err("failed to load session")?;
355
356        ensure_loaded_session_network_matches(
357            &self.session.source.config.foundry_config,
358            &new_session.source.config.foundry_config,
359            id,
360        )?;
361        new_session.source.config.foundry_config.force =
362            self.session.source.config.foundry_config.force;
363        new_session.source.config.restore_credentials(&self.invocation_rpc)?;
364        new_session.source.config.initialize_local_context();
365        new_session.source.build()?;
366        self.session = new_session;
367        self.last_result = None;
368        sh_println!(
369            "Loaded Chisel session! (ID = {})",
370            self.session.id.as_deref().unwrap_or("<unknown>")
371        )
372    }
373
374    pub(crate) fn list_sessions(&self) -> Result<()> {
375        let sessions = ChiselSession::<FEN>::get_sessions()?;
376        if sessions.is_empty() {
377            eyre::bail!("No sessions found. Use the `!save` command to save a session.");
378        }
379        sh_println!(
380            "{}\n{}",
381            format!("{CHISEL_CHAR} Chisel Sessions").cyan(),
382            sessions
383                .iter()
384                .map(|(time, name)| format!("{} - {}", format!("{time:?}").blue(), name))
385                .collect::<Vec<String>>()
386                .join("\n")
387        )
388    }
389
390    pub(crate) fn show_source(&self) -> Result<()> {
391        let formatted = self.format_source().wrap_err("failed to format session source")?;
392        let highlighted = self.helper.highlight(&formatted);
393        sh_println!("{highlighted}")
394    }
395
396    pub(crate) fn clear_cache(&mut self) -> Result<()> {
397        ChiselSession::<FEN>::clear_cache().wrap_err("failed to clear cache")?;
398        self.session.id = None;
399        sh_println!("Cleared chisel cache!")
400    }
401
402    pub(crate) async fn set_fork(&mut self, url: Option<String>) -> Result<()> {
403        self.source_mut().config.initialize_local_context();
404
405        let Some(url) = url else {
406            return self.clear_fork();
407        };
408
409        // If the argument is an RPC alias designated in the
410        // `[rpc_endpoints]` section of the `foundry.toml` within
411        // the pwd, use the URL matched to the key.
412        let endpoint = if let Some(endpoint) =
413            self.source_mut().config.foundry_config.rpc_endpoints.get(&url)
414        {
415            endpoint.clone()
416        } else {
417            RpcEndpointUrl::Env(url).into()
418        };
419        let endpoint = endpoint.resolve();
420        let fork_url = endpoint.url()?;
421        let fork_headers =
422            endpoint.auth.transpose()?.map(|auth| vec![format!("Authorization: {auth}")]);
423
424        if let Err(e) = Url::parse(&fork_url) {
425            eyre::bail!("invalid fork URL: {e}");
426        }
427
428        let mut fork_opts = self.source().config.evm_opts.clone();
429        fork_opts.fork_url = Some(fork_url.clone());
430        fork_opts.fork_headers = fork_headers;
431        fork_opts.fork_block_number = None;
432        fork_opts.fork_block_number_is_inferred = false;
433        let explicit_network =
434            fork_opts.networks.has_network_selection() && !fork_opts.fork_network_is_inferred;
435        let identity = fork_opts.discover_fork_endpoint().await?;
436        let target = identity.network;
437        let current_opts = &self.source().config.evm_opts;
438        let current = network_variant(current_opts.networks);
439        ensure_fork_network_matches(current, target)?;
440
441        let networks = if explicit_network {
442            current_opts.networks
443        } else {
444            current_opts.networks.with_rpc_profile(identity.network_profile)
445        };
446        if fork_opts.env.chain_id.is_none() || fork_opts.fork_chain_id_is_inferred {
447            fork_opts.env.chain_id = Some(identity.execution_chain_id);
448            fork_opts.fork_chain_id_is_inferred = true;
449        }
450        fork_opts.networks = networks;
451        fork_opts.fork_endpoint = Some(identity.clone());
452        fork_opts.fork_network_is_inferred = !explicit_network;
453        fork_opts.pin_fork_block().await?;
454        self.invocation_rpc.set_fork(fork_url.clone(), fork_opts.fork_headers.clone());
455        let chain_id_is_inferred = fork_opts.fork_chain_id_is_inferred;
456        let source = self.source_mut();
457        source.config.evm_opts = fork_opts;
458        source.config.fork_network_is_inferred = !explicit_network;
459        source.config.fork_chain_id_is_inferred = chain_id_is_inferred;
460        source.config.foundry_config.networks = networks;
461        source.config.foundry_config.chain = Some(Chain::from(identity.source_chain_id));
462        source.config.resolved_hardfork = None;
463        source.config.source_chain_id = None;
464        source.config.fork_url_required = false;
465        // Clear the backend so that it is re-instantiated with the new fork
466        // upon the next execution of the session source.
467        source.config.cached_backend = None;
468
469        sh_println!("Set fork URL to {}", fork_url.yellow())?;
470
471        Ok(())
472    }
473
474    fn clear_fork(&mut self) -> Result<()> {
475        let current = network_variant(self.source().config.evm_opts.networks);
476        let local_networks =
477            self.source().config.local_networks.unwrap_or(self.source().config.evm_opts.networks);
478        let target = network_variant(local_networks);
479        ensure_fork_network_matches(current, target)?;
480
481        let source = self.source_mut();
482        source.config.evm_opts.fork_url = None;
483        source.config.fork_url_required = false;
484        source.config.evm_opts.fork_block_number = None;
485        source.config.evm_opts.fork_block_number_is_inferred = false;
486        source.config.evm_opts.networks = local_networks;
487        source.config.evm_opts.env.chain_id = source.config.local_chain_id;
488        source.config.evm_opts.fork_network_is_inferred = false;
489        source.config.evm_opts.fork_chain_id_is_inferred = false;
490        source.config.fork_network_is_inferred = false;
491        source.config.fork_chain_id_is_inferred = false;
492        source.config.foundry_config.networks = local_networks;
493        source.config.foundry_config.chain = source.config.local_chain_id.map(Chain::from);
494        source.config.resolved_hardfork = None;
495        source.config.source_chain_id = None;
496        source.config.cached_backend = None;
497        sh_println!("Now using local environment.")
498    }
499
500    pub(crate) fn toggle_traces(&mut self) -> Result<()> {
501        let t = &mut self.source_mut().config.traces;
502        *t = !*t;
503        sh_println!("{} traces!", if *t { "Enabled" } else { "Disabled" })
504    }
505
506    pub(crate) fn set_calldata(&mut self, data: Option<&str>) -> Result<()> {
507        // remove empty space, double quotes, and 0x prefix
508        let arg = data
509            .map(|s| s.trim_matches(|c: char| c.is_whitespace() || c == '"' || c == '\''))
510            .map(|s| s.strip_prefix("0x").unwrap_or(s))
511            .unwrap_or("");
512
513        if arg.is_empty() {
514            self.source_mut().config.calldata = None;
515            sh_println!("Calldata cleared.")?;
516            return Ok(());
517        }
518
519        let calldata = hex::decode(arg);
520        match calldata {
521            Ok(calldata) => {
522                self.source_mut().config.calldata = Some(calldata);
523                sh_println!("Set calldata to '{}'", arg.yellow())
524            }
525            Err(e) => {
526                eyre::bail!("Invalid calldata: {e}");
527            }
528        }
529    }
530
531    pub(crate) async fn show_mem_dump(&mut self) -> Result<()> {
532        let res = self.source_mut().execute().await?;
533        let Some((_, mem)) = res.state.as_ref() else {
534            eyre::bail!("Run function is empty.");
535        };
536        for i in (0..mem.len()).step_by(32) {
537            let _ = sh_println!(
538                "{}: {}",
539                format!("[0x{:02x}:0x{:02x}]", i, i + 32).yellow(),
540                hex::encode_prefixed(&mem[i..i + 32]).cyan()
541            );
542        }
543        Ok(())
544    }
545
546    pub(crate) async fn show_stack_dump(&mut self) -> Result<()> {
547        let res = self.source_mut().execute().await?;
548        let Some((stack, _)) = res.state.as_ref() else {
549            eyre::bail!("Run function is empty.");
550        };
551        for i in (0..stack.len()).rev() {
552            let _ = sh_println!(
553                "{}: {}",
554                format!("[{}]", stack.len() - i - 1).yellow(),
555                format!("0x{:02x}", stack[i]).cyan()
556            );
557        }
558        Ok(())
559    }
560
561    pub(crate) fn export(&self) -> Result<()> {
562        // Check if the pwd is a foundry project
563        if !Path::new("foundry.toml").exists() {
564            eyre::bail!("Must be in a foundry project to export source to script.");
565        }
566
567        // Create "script" dir if it does not already exist.
568        if !Path::new("script").exists() {
569            std::fs::create_dir_all("script")?;
570        }
571
572        let formatted_source = self.format_source()?;
573        std::fs::write(PathBuf::from("script/REPL.s.sol"), formatted_source)?;
574        sh_println!("Exported session source to script/REPL.s.sol!")
575    }
576
577    /// Fetches an interface from Etherscan
578    pub(crate) async fn fetch_interface(&mut self, address: Address, name: String) -> Result<()> {
579        let abis = fetch_abi_from_etherscan(address, &self.source().config.foundry_config, false)
580            .await
581            .wrap_err("Failed to fetch ABI from Etherscan")?;
582        let (abi, _) = abis
583            .into_iter()
584            .next()
585            .ok_or_else(|| eyre::eyre!("No ABI found for address {address} on Etherscan"))?;
586        let code = forge_fmt::format(&abi.to_sol(&name, None), FormatterConfig::default())
587            .into_result()?;
588        self.source_mut().add_global_code(&code);
589        sh_println!("Added {address}'s interface to source as `{name}`")
590    }
591
592    pub(crate) fn exec_command(&self, command: String, args: Vec<String>) -> Result<()> {
593        let mut cmd = Command::new(command);
594        cmd.args(args);
595        let _ = cmd.status()?;
596        Ok(())
597    }
598
599    pub(crate) async fn edit_session(&mut self) -> Result<()> {
600        // create a temp file with the content of the run code
601        let mut tmp = Builder::new()
602            .prefix("chisel-")
603            .suffix(".sol")
604            .tempfile()
605            .wrap_err("Could not create temporary file")?;
606        tmp.as_file_mut()
607            .write_all(self.source().run_code.as_bytes())
608            .wrap_err("Could not write to temporary file")?;
609
610        // open the temp file with the editor
611        let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
612        let mut cmd = Command::new(editor);
613        cmd.arg(tmp.path());
614        let st = cmd.status()?;
615        if !st.success() {
616            eyre::bail!("Editor exited with {st}");
617        }
618
619        let edited_code = std::fs::read_to_string(tmp.path())?;
620        let mut new_source = self.source().clone();
621        new_source.clear_run();
622        new_source.add_run_code(&edited_code);
623
624        // if the editor exited successfully, try to compile the new code
625        self.execute_and_replace(new_source).await?;
626        sh_println!("Successfully edited `run()` function's body!")
627    }
628
629    pub(crate) async fn show_raw_stack(&mut self, var: String) -> Result<()> {
630        let source = self.source_mut();
631        let line = format!("bytes32 __raw__; assembly {{ __raw__ := {var} }}");
632        if let Ok((new_source, _)) = source.clone_with_new_line(line)
633            && let InspectResult { formatted_output: Some(res), .. } =
634                new_source.inspect("__raw__").await?
635        {
636            sh_println!("{res}")?;
637            return Ok(());
638        }
639
640        eyre::bail!("Variable must exist within `run()` function.");
641    }
642}
643
644fn config_network_name(config: &Config) -> &'static str {
645    config.networks.active_network_name().unwrap_or("ethereum")
646}
647
648fn network_variant(networks: NetworkConfigs) -> NetworkVariant {
649    networks.resolved_network().unwrap_or_default()
650}
651
652fn ensure_fork_network_matches(current: NetworkVariant, target: NetworkVariant) -> Result<()> {
653    if current != target {
654        eyre::bail!(
655            "cannot switch this Chisel session from network `{current}` to `{target}`. Restart \
656             Chisel with `--network {target}` or a fork URL for that network.",
657        );
658    }
659    Ok(())
660}
661
662fn ensure_loaded_session_network_matches(
663    current: &Config,
664    loaded: &Config,
665    id: &str,
666) -> Result<()> {
667    let current_network = config_network_name(current);
668    let loaded_network = config_network_name(loaded);
669    if current_network != loaded_network {
670        eyre::bail!(
671            "Chisel session `{id}` was saved for network `{loaded_network}`, but the current \
672             network is `{current_network}`. Rerun with `--network {loaded_network}` to load it.",
673        );
674    }
675    Ok(())
676}
677
678/// Expands the previous result, checksums addresses, and returns whether the input only contained
679/// trivia (comments, whitespace).
680fn preprocess<'a>(input: &'a str, last_result: Option<&str>) -> Result<(bool, Cow<'a, str>)> {
681    let mut only_trivia = true;
682    let mut replacements = Vec::new();
683    for (pos, token) in solar::parse::Cursor::new(input).with_position() {
684        use RawTokenKind::{BlockComment, LineComment, Literal, Whitespace};
685
686        if matches!(token.kind, Whitespace | LineComment { .. } | BlockComment { .. }) {
687            continue;
688        }
689        only_trivia = false;
690
691        let range = pos..pos + token.len as usize;
692        if &input[range.clone()] == "$_" {
693            let last_result = last_result.ok_or_else(|| eyre::eyre!("no previous result"))?;
694            replacements.push((range, format!("({last_result})")));
695            continue;
696        }
697
698        // Ensure that addresses are correctly checksummed.
699        if let Literal { kind: RawLiteralKind::Int { base: Base::Hexadecimal, .. } } = token.kind
700            && token.len == 42
701            && let Ok(addr) = input[range.clone()].parse::<Address>()
702        {
703            replacements.push((range, addr.to_checksum_buffer(None).to_string()));
704        }
705    }
706
707    if replacements.is_empty() {
708        Ok((only_trivia, Cow::Borrowed(input)))
709    } else {
710        let mut new_input = input.to_string();
711        for (range, replacement) in replacements.into_iter().rev() {
712            new_input.replace_range(range, &replacement);
713        }
714        Ok((only_trivia, Cow::Owned(new_input)))
715    }
716}
717
718/// Displays cached source without restoring credentials or building an execution environment.
719pub(crate) fn view_session(id: &str) -> Result<()> {
720    // Source rendering is independent of the session's execution network.
721    let executor_builder = ExecutorBuilder::<EthEvmNetwork>::new();
722    let session = match id {
723        "latest" => ChiselSession::latest(executor_builder),
724        id => ChiselSession::load(id, executor_builder),
725    }
726    .wrap_err("failed to load session")?;
727    sh_println!("Loaded Chisel session! (ID = {})", session.id.as_deref().unwrap_or("<unknown>"))?;
728    let formatted =
729        format_source(&session.source.to_repl_source(), session.source.config.foundry_config.fmt)
730            .wrap_err("failed to format session source")?;
731    sh_println!("{}", SolidityHelper::default().highlight(&formatted))
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use foundry_evm::opts::EvmOpts;
738
739    fn config_with_network(network: Option<&str>) -> Config {
740        let mut config = Config::default();
741        if let Some(network) = network {
742            config.networks = serde_json::from_value(serde_json::json!({
743                "network": network,
744                "celo": false,
745                "bypass_prevrandao": false,
746            }))
747            .unwrap();
748        }
749        config
750    }
751
752    #[test]
753    fn config_network_name_defaults_to_ethereum() {
754        assert_eq!(config_network_name(&Config::default()), "ethereum");
755    }
756
757    #[test]
758    fn ensure_fork_network_matches_accepts_same_family() {
759        ensure_fork_network_matches(NetworkVariant::Ethereum, NetworkVariant::Ethereum).unwrap();
760        ensure_fork_network_matches(NetworkVariant::Tempo, NetworkVariant::Tempo).unwrap();
761    }
762
763    #[tokio::test(flavor = "multi_thread")]
764    async fn setting_fork_preserves_explicit_celo_context() {
765        let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await;
766        let networks = NetworkConfigs::with_celo();
767        let config = SessionSourceConfig::<EthEvmNetwork> {
768            foundry_config: Config {
769                networks,
770                solc: Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 29))),
771                ..Default::default()
772            },
773            evm_opts: EvmOpts { networks, ..Default::default() },
774            local_networks: Some(networks),
775            ..Default::default()
776        };
777        let mut dispatcher = ChiselDispatcher::new(config).unwrap();
778
779        dispatcher.set_fork(Some(handle.http_endpoint())).await.unwrap();
780        assert!(dispatcher.source().config.evm_opts.networks.is_celo());
781        assert!(!dispatcher.source().config.evm_opts.fork_network_is_inferred);
782        let encoded = serde_json::to_string(&dispatcher.source().config).unwrap();
783        let mut restored =
784            serde_json::from_str::<SessionSourceConfig<EthEvmNetwork>>(&encoded).unwrap();
785        restored.restore_credentials(&dispatcher.invocation_rpc).unwrap();
786        assert_eq!(restored.evm_opts.fork_url, Some(handle.http_endpoint()));
787
788        dispatcher.clear_fork().unwrap();
789        assert!(dispatcher.source().config.evm_opts.networks.is_celo());
790    }
791
792    #[test]
793    #[cfg(feature = "monad")]
794    fn ensure_fork_network_matches_rejects_cross_family_change() {
795        let err = ensure_fork_network_matches(NetworkVariant::Ethereum, NetworkVariant::Monad)
796            .unwrap_err();
797        assert_eq!(
798            err.to_string(),
799            "cannot switch this Chisel session from network `ethereum` to `monad`. Restart Chisel \
800             with `--network monad` or a fork URL for that network."
801        );
802    }
803
804    #[test]
805    #[cfg(feature = "monad")]
806    fn clearing_startup_fork_preserves_inferred_monad_context() {
807        let networks = NetworkConfigs::with_monad();
808        let evm_opts = EvmOpts {
809            fork_url: Some("http://localhost:8545".to_string()),
810            networks,
811            env: foundry_evm::opts::Env { chain_id: Some(143), ..Default::default() },
812            ..Default::default()
813        };
814        let config = SessionSourceConfig::<foundry_evm::core::evm::MonadEvmNetwork> {
815            foundry_config: Config {
816                solc: Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 29))),
817                networks,
818                chain: Some(Chain::from(143u64)),
819                ..Default::default()
820            },
821            evm_opts,
822            local_networks: Some(networks),
823            local_chain_id: Some(143),
824            ..Default::default()
825        };
826        let mut dispatcher = ChiselDispatcher::new(config).unwrap();
827
828        dispatcher.clear_fork().unwrap();
829
830        let config = &dispatcher.source().config;
831        assert!(config.evm_opts.fork_url.is_none());
832        assert!(config.evm_opts.networks.is_monad());
833        assert_eq!(config.evm_opts.env.chain_id, Some(143));
834        assert!(config.foundry_config.networks.is_monad());
835        assert_eq!(config.foundry_config.chain.map(|chain| chain.id()), Some(143));
836    }
837
838    #[test]
839    fn ensure_loaded_session_network_matches_rejects_different_network() {
840        let current = config_with_network(None);
841        let loaded = config_with_network(Some("tempo"));
842
843        let err = ensure_loaded_session_network_matches(&current, &loaded, "42").unwrap_err();
844        assert_eq!(
845            err.to_string(),
846            "Chisel session `42` was saved for network `tempo`, but the current network is \
847             `ethereum`. Rerun with `--network tempo` to load it."
848        );
849    }
850
851    #[test]
852    #[cfg(feature = "monad")]
853    fn ensure_loaded_session_network_matches_rejects_monad_on_default_network() {
854        let current = config_with_network(None);
855        let loaded = config_with_network(Some("monad"));
856
857        let err = ensure_loaded_session_network_matches(&current, &loaded, "43").unwrap_err();
858        assert_eq!(
859            err.to_string(),
860            "Chisel session `43` was saved for network `monad`, but the current network is \
861             `ethereum`. Rerun with `--network monad` to load it."
862        );
863    }
864
865    #[test]
866    fn ensure_loaded_session_network_matches_accepts_same_network() {
867        let current = config_with_network(Some("tempo"));
868        let loaded = config_with_network(Some("tempo"));
869
870        ensure_loaded_session_network_matches(&current, &loaded, "42").unwrap();
871    }
872
873    #[cfg(feature = "base")]
874    #[test]
875    fn ensure_loaded_session_network_matches_preserves_base() {
876        let base = config_with_network(Some("base"));
877        ensure_loaded_session_network_matches(&base, &base, "42").unwrap();
878
879        let err =
880            ensure_loaded_session_network_matches(&Config::default(), &base, "42").unwrap_err();
881        assert!(err.to_string().contains("Rerun with `--network base`"), "{err}");
882    }
883
884    #[test]
885    fn test_trivia() {
886        fn only_trivia(s: &str) -> bool {
887            let (only_trivia, _new_input) = preprocess(s, None).unwrap();
888            only_trivia
889        }
890        assert!(only_trivia("// line comment"));
891        assert!(only_trivia("  \n// line \tcomment\n"));
892        assert!(!only_trivia("// line \ncomment"));
893
894        assert!(only_trivia("/* block comment */"));
895        assert!(only_trivia(" \t\n  /* block \n \t comment */\n"));
896        assert!(!only_trivia("/* block \n \t comment */\nwith \tother"));
897    }
898
899    #[test]
900    fn test_last_result_preprocessing() {
901        let result = "abi.decode(hex\"2a\", (uint256))";
902        let (_, input) = preprocess("uint256 answer = $_;", Some(result)).unwrap();
903        assert_eq!(input, format!("uint256 answer = ({result});"));
904
905        let literal = r#"string memory value = "$_"; // $_"#;
906        let (_, input) = preprocess(literal, Some(result)).unwrap();
907        assert_eq!(input, literal);
908
909        assert_eq!(preprocess("$_", None).unwrap_err().to_string(), "no previous result");
910    }
911
912    #[tokio::test(flavor = "multi_thread")]
913    async fn explicit_fork_refreshes_credentials_used_by_saved_sessions() {
914        let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await;
915        let endpoint = handle.http_endpoint();
916        let config = SessionSourceConfig::<EthEvmNetwork> {
917            foundry_config: Config {
918                solc: Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 29))),
919                rpc_endpoints: serde_json::from_value(serde_json::json!({
920                    "current": { "endpoint": endpoint, "auth": "Bearer current-token" }
921                }))
922                .unwrap(),
923                ..Default::default()
924            },
925            evm_opts: EvmOpts {
926                fork_url: Some("https://old.invalid/old-token".into()),
927                fork_headers: Some(vec!["Authorization: Bearer old-token".into()]),
928                rpc_headers: Some(vec!["X-Invocation: current".into()]),
929                ..Default::default()
930            },
931            ..Default::default()
932        };
933        let mut dispatcher = ChiselDispatcher::new(config).unwrap();
934
935        dispatcher.set_fork(Some("current".into())).await.unwrap();
936        let encoded = serde_json::to_string(&dispatcher.source().config).unwrap();
937        // Leaving fork mode keeps the invocation's endpoint available for later session loads.
938        dispatcher.clear_fork().unwrap();
939        let mut restored =
940            serde_json::from_str::<SessionSourceConfig<EthEvmNetwork>>(&encoded).unwrap();
941        restored.restore_credentials(&dispatcher.invocation_rpc).unwrap();
942
943        assert_eq!(restored.evm_opts.fork_url.as_deref(), Some(endpoint.as_str()));
944        assert_eq!(restored.foundry_config.eth_rpc_url.as_deref(), Some(endpoint.as_str()));
945        assert_eq!(
946            restored.evm_opts.fork_headers,
947            Some(vec!["Authorization: Bearer current-token".into()])
948        );
949        assert_eq!(restored.evm_opts.rpc_headers, Some(vec!["X-Invocation: current".into()]));
950
951        // Switching to a raw URL drops the previous alias's endpoint-specific authorization.
952        dispatcher.set_fork(Some(endpoint.clone())).await.unwrap();
953        restored.restore_credentials(&dispatcher.invocation_rpc).unwrap();
954        assert_eq!(restored.evm_opts.fork_headers, None);
955        assert_eq!(restored.evm_opts.rpc_headers, Some(vec!["X-Invocation: current".into()]));
956
957        assert!(dispatcher.set_fork(Some("not-a-url".into())).await.is_err());
958        restored.restore_credentials(&dispatcher.invocation_rpc).unwrap();
959        assert_eq!(restored.evm_opts.fork_url.as_deref(), Some(endpoint.as_str()));
960    }
961}