1use crate::cli::output::Output;
9use crate::cli::tui::Settings;
10use crate::cli::{Cli, Commands};
11use crate::{note, out};
12use clap::CommandFactory;
13use clap_complete::generate;
14use scope::Config;
15use scope::chains::{ChainClientFactory, DefaultClientFactory};
16use scope::config::OutputFormat;
17use scope::error::{Result, ScopeError};
18use scope::http::HttpClient;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22#[derive(Debug, Clone, Default)]
25pub struct ConfigSource {
26 pub path: Option<PathBuf>,
28 pub ai: bool,
30}
31
32impl ConfigSource {
33 pub fn load(&self) -> Result<Config> {
39 let mut config = Config::load(self.path.as_deref())?;
40 self.apply(&mut config);
41 Ok(config)
42 }
43
44 pub fn apply(&self, config: &mut Config) {
46 if self.ai {
47 config.output.format = OutputFormat::Markdown;
48 }
49 }
50}
51
52pub async fn dispatch(
65 command: Commands,
66 source: &ConfigSource,
67 config: &Config,
68 out: &Output,
69) -> Result<i32> {
70 use crate::cli::ghola_offer;
73 let offer = !command.is_interactive()
74 && ghola_offer::may_ask(out)
75 && !ghola_offer::scripted(&ghola_offer::process_args(), config);
76 let env = |k: &str| std::env::var(k).ok();
77 let http: Arc<dyn HttpClient> = create_http_client_offering(config, out, &env, offer)?;
78 let opens_tui = match &command {
81 Commands::Interactive(_) => true,
82 Commands::Monitor(args) => !args.watch.dashboard,
83 _ => false,
84 };
85 if opens_tui {
86 let settings = |config: Config, http: Arc<dyn HttpClient>| Settings {
87 clients: Box::new(DefaultClientFactory {
88 chains_config: config.chains.clone(),
89 http,
90 }),
91 config,
92 };
93 let reload = || {
96 let config = source.load()?;
97 let http = transport_for_reload(&config, &http, out, &|k| std::env::var(k).ok())?;
98 Ok(settings(config, http))
99 };
100 let first = settings(config.clone(), http.clone());
101 let exit = match command {
102 Commands::Monitor(args) => {
103 crate::cli::interactive::run_monitor(args, first, &reload).await?
104 }
105 Commands::Interactive(args) => {
106 crate::cli::interactive::run(args, first, &reload).await?
107 }
108 _ => unreachable!("opens_tui is true only for these two"),
109 };
110 return Ok(exit.code);
111 }
112 let factory = DefaultClientFactory {
113 chains_config: config.chains.clone(),
114 http,
115 };
116 dispatch_with(command, config, &factory, out)
117 .await
118 .map(|()| 0)
119}
120
121pub async fn dispatch_with(
132 command: Commands,
133 config: &Config,
134 clients: &dyn ChainClientFactory,
135 out: &Output,
136) -> Result<()> {
137 match command {
138 Commands::Completions(args) => {
139 let mut cmd = Cli::command();
140 let mut buf = Vec::new();
141 generate(args.shell, &mut cmd, "scope", &mut buf);
142 out!(out, "{}", String::from_utf8_lossy(&buf))?;
143 Ok(())
144 }
145 Commands::Address(args) => crate::cli::address::run(args, config, clients, out).await,
146 Commands::Tx(args) => crate::cli::tx::run(args, config, clients, out).await,
147 Commands::Crawl(args) => crate::cli::crawl::run(args, config, clients, out).await,
148 Commands::AddressBook(args) => {
149 crate::cli::address_book::run(args, config, clients, out).await
150 }
151 Commands::Export(args) => crate::cli::export::run(args, config, clients, out).await,
152 Commands::Interactive(_) => Err(ScopeError::Other(
155 "the TUI cannot start from here; start it with `scope tui`".into(),
156 )),
157 Commands::Monitor(args) => {
158 crate::cli::monitor::run_direct(args, config, clients, out).await
159 }
160 Commands::Setup(args) => crate::cli::setup::run(args, config, out).await,
161 Commands::Compliance(compliance_cmd) => {
162 dispatch_compliance(compliance_cmd, config, clients.http(), out).await
163 }
164 Commands::Market(cmd) => crate::cli::market::run(cmd, config, clients, out).await,
165 Commands::TokenHealth(args) => {
166 crate::cli::token_health::run(args, config, clients, out).await
167 }
168 Commands::Venues(cmd) => crate::cli::venues::run(cmd, out),
169 Commands::Report(cmd) => crate::cli::report::run(cmd, config, clients, out).await,
170 Commands::Discover(args) => {
171 crate::cli::discover::run(args, config.output.format, clients.http(), out)
172 .await
173 .map_err(|e| match e {
174 ScopeError::IoError(_) => e,
176 other => ScopeError::Discovery(other.to_string()),
177 })
178 }
179 Commands::Insights(args) => crate::cli::insights::run(args, config, clients, out).await,
180 Commands::Contract(ref args) => crate::cli::contract::run(args, config, clients, out).await,
181 Commands::Web(_) => Err(ScopeError::Other(
182 "the web server cannot run from here; start it with `scope web`".into(),
183 )),
184 }
185}
186
187async fn dispatch_compliance(
190 cmd: crate::cli::compliance::ComplianceCommands,
191 config: &Config,
192 http: Arc<dyn HttpClient>,
193 out: &Output,
194) -> Result<()> {
195 use crate::cli::address_book::resolve_address_book_input;
196 use crate::cli::compliance::{self, ComplianceCommands};
197
198 let result = match cmd {
199 ComplianceCommands::Risk(mut args) => {
200 if let Some((addr, chain)) = resolve_address_book_input(&args.address, config)? {
201 args.address = addr;
202 if args.chain.is_none() {
203 args.chain = Some(chain);
204 }
205 }
206 compliance::handle_risk(args, http, out).await
207 }
208 ComplianceCommands::Trace(args) => compliance::handle_trace(args, http, out).await,
209 ComplianceCommands::Analyze(mut args) => {
210 if let Some((addr, _chain)) = resolve_address_book_input(&args.address, config)? {
211 args.address = addr;
212 }
213 compliance::handle_analyze(args, http, out).await
214 }
215 ComplianceCommands::ComplianceReport(mut args) => {
216 if !std::path::Path::new(&args.target).exists()
217 && let Some((addr, _chain)) = resolve_address_book_input(&args.target, config)?
218 {
219 args.target = addr;
220 }
221 compliance::handle_compliance_report(args, http, out).await
222 }
223 };
224 result.map_err(|e| from_anyhow(e, ScopeError::Compliance))
225}
226
227fn from_anyhow(e: anyhow::Error, kind: fn(String) -> ScopeError) -> ScopeError {
231 match e.downcast::<std::io::Error>() {
232 Ok(io) => ScopeError::IoError(io),
233 Err(e) => match e.downcast::<ScopeError>() {
234 Ok(scope) => scope,
235 Err(e) => kind(e.to_string()),
236 },
237 }
238}
239
240pub fn create_http_client(config: &Config, out: &Output) -> Result<Arc<dyn HttpClient>> {
247 create_http_client_with(config, out, &|k| std::env::var(k).ok())
248}
249
250fn create_http_client_with(
252 config: &Config,
253 out: &Output,
254 env: &dyn Fn(&str) -> Option<String>,
255) -> Result<Arc<dyn HttpClient>> {
256 create_http_client_offering(config, out, env, false)
257}
258
259fn create_http_client_offering(
263 config: &Config,
264 out: &Output,
265 env: &dyn Fn(&str) -> Option<String>,
266 offer: bool,
267) -> Result<Arc<dyn HttpClient>> {
268 let mut built = scope::http::shared::build_with(config, env)?;
269 if offer && built.ghola_missing && crate::cli::ghola_offer::offer_for_transport(out).is_some() {
270 built = scope::http::shared::build_with(config, env)?;
271 }
272 for notice in &built.notices {
273 note!(out, "{notice}");
274 }
275 Ok(built.client)
276}
277
278fn transport_for_reload(
283 config: &Config,
284 current: &Arc<dyn HttpClient>,
285 out: &Output,
286 env: &dyn Fn(&str) -> Option<String>,
287) -> Result<Arc<dyn HttpClient>> {
288 use scope::http::shared::{Kind, configured_kind_with};
289 match configured_kind_with(config, env) {
290 Kind::Replay | Kind::Record => Ok(current.clone()),
291 Kind::Native | Kind::Ghola => create_http_client_with(config, out, env),
292 }
293}
294
295#[cfg(test)]
300pub(crate) mod tests {
301 pub(crate) struct OfflineFactory {
307 pub(crate) mock: scope::chains::mocks::MockClientFactory,
308 http: std::sync::Arc<dyn scope::http::HttpClient>,
309 _fixtures: tempfile::TempDir,
310 }
311
312 impl OfflineFactory {
313 pub(crate) fn new() -> Self {
314 let fixtures = tempfile::tempdir().unwrap();
315 Self {
316 mock: scope::chains::mocks::MockClientFactory::new(),
317 http: std::sync::Arc::new(scope::http::replay::ReplayHttpClient::replay(
318 fixtures.path().to_path_buf(),
319 )),
320 _fixtures: fixtures,
321 }
322 }
323 }
324
325 impl scope::chains::ChainClientFactory for OfflineFactory {
326 fn create_chain_client(
327 &self,
328 chain: &str,
329 ) -> scope::error::Result<Box<dyn scope::chains::ChainClient>> {
330 self.mock.create_chain_client(chain)
331 }
332
333 fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
334 self.mock.create_dex_client()
335 }
336
337 fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
338 self.http.clone()
339 }
340 }
341
342 #[test]
343 fn test_a_config_source_reads_its_file_and_applies_ai() {
344 use super::ConfigSource;
345 use scope::config::OutputFormat;
346 let dir = tempfile::tempdir().unwrap();
347 let path = dir.path().join("c.yaml");
348 std::fs::write(&path, "output:\n format: json\n").unwrap();
349 let plain = ConfigSource {
350 path: Some(path.clone()),
351 ai: false,
352 };
353 assert_eq!(plain.load().unwrap().output.format, OutputFormat::Json);
354 let ai = ConfigSource {
355 path: Some(path),
356 ai: true,
357 };
358 assert_eq!(ai.load().unwrap().output.format, OutputFormat::Markdown);
359 }
360
361 #[test]
362 fn test_anyhow_io_errors_keep_their_kind() {
363 let pipe = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "closed");
366 let e = super::from_anyhow(pipe.into(), ScopeError::Compliance);
367 assert!(
368 matches!(&e, ScopeError::IoError(io) if io.kind() == std::io::ErrorKind::BrokenPipe)
369 );
370 let scope = super::from_anyhow(
371 ScopeError::NotFound("x".into()).into(),
372 ScopeError::Compliance,
373 );
374 assert!(matches!(scope, ScopeError::NotFound(_)));
375 let other = super::from_anyhow(anyhow::anyhow!("bad"), ScopeError::Compliance);
376 assert_eq!(
377 other.to_string(),
378 ScopeError::Compliance("bad".into()).to_string()
379 );
380 }
381
382 use super::*;
383 use clap::Parser;
384 use scope::http::shared::Kind;
385
386 fn quiet() -> Output {
387 Output::capture().0
388 }
389
390 #[test]
391 fn test_create_http_client_builds_a_new_transport_each_call() {
392 let config = Config::default();
394 let (out, captured) = Output::capture();
395 let a = create_http_client_with(&config, &out, &|_| None).unwrap();
396 let b = create_http_client_with(&config, &out, &|_| None).unwrap();
397 assert!(!Arc::ptr_eq(&a, &b));
398 assert!(captured.err().is_empty(), "{}", captured.err());
400 }
401
402 #[test]
403 fn test_a_reload_builds_the_transport_again() {
404 let (out, _) = Output::capture();
406 let current = create_http_client_with(&Config::default(), &out, &|_| None).unwrap();
407 let next = transport_for_reload(&Config::default(), ¤t, &out, &|_| None).unwrap();
408 assert!(!Arc::ptr_eq(¤t, &next));
409 }
410
411 #[test]
412 fn test_a_reload_keeps_the_replay_and_record_transport() {
413 let (out, _) = Output::capture();
416 let current = create_http_client_with(&Config::default(), &out, &|_| None).unwrap();
417 for mode in ["replay", "record"] {
418 let env = move |k: &str| match k {
419 "SCOPE_HTTP_MODE" => Some(mode.to_string()),
420 "SCOPE_HTTP_FIXTURES" => Some("unused".to_string()),
421 _ => None,
422 };
423 let next = transport_for_reload(&Config::default(), ¤t, &out, &env).unwrap();
424 assert!(Arc::ptr_eq(¤t, &next), "{mode}");
425 }
426 }
427
428 fn assert_ghola_selection(stealth: bool) {
432 let mut config = Config::default();
433 config.ghola.enabled = true;
434 config.ghola.stealth = stealth;
435 let built = scope::http::shared::build_with(&config, &|_| None).unwrap();
436 match built.kind {
437 Kind::Ghola => assert!(built.notices.is_empty(), "{:?}", built.notices),
438 Kind::Native => {
439 let all = built.notices.join("\n");
440 assert!(all.contains("Ghola"), "{all}");
441 assert!(all.contains("Falling back"), "{all}");
442 }
443 other => panic!("unexpected kind {other:?}"),
444 }
445 }
446
447 #[test]
448 fn test_transport_selection_default_config_is_native() {
449 let built = scope::http::shared::build_with(&Config::default(), &|_| None).unwrap();
450 assert_eq!(built.kind, Kind::Native);
451 assert!(built.notices.is_empty(), "{:?}", built.notices);
452 }
453
454 #[test]
455 fn test_transport_selection_ghola_enabled() {
456 assert_ghola_selection(true);
457 }
458
459 #[test]
460 fn test_transport_selection_ghola_no_stealth() {
461 assert_ghola_selection(false);
462 }
463
464 #[tokio::test]
465 async fn test_dispatch_writes_completions_to_output() {
466 let cli = Cli::try_parse_from(["scope", "completions", "bash"]).unwrap();
469 let (o, cap) = Output::capture();
470 dispatch(
471 cli.command,
472 &ConfigSource::default(),
473 &Config::default(),
474 &o,
475 )
476 .await
477 .unwrap();
478 assert!(cap.out().contains("_scope()"));
479 }
480
481 #[tokio::test]
482 async fn test_dispatch_fails_when_output_cannot_be_written() {
483 let cli = Cli::try_parse_from(["scope", "completions", "zsh"]).unwrap();
486 let o = Output::from_writers(
487 crate::cli::output::tests::ClosedPipe,
488 crate::cli::output::tests::ClosedPipe,
489 );
490 let err = dispatch(
491 cli.command,
492 &ConfigSource::default(),
493 &Config::default(),
494 &o,
495 )
496 .await
497 .unwrap_err();
498 assert!(
499 matches!(err, ScopeError::IoError(ref e) if e.kind() == std::io::ErrorKind::BrokenPipe)
500 );
501 }
502
503 #[tokio::test]
504 async fn test_dispatch_rejects_web() {
505 let cli = Cli::try_parse_from(["scope", "web"]).unwrap();
507 let err = dispatch(
508 cli.command,
509 &ConfigSource::default(),
510 &Config::default(),
511 &quiet(),
512 )
513 .await
514 .unwrap_err();
515 assert!(err.to_string().contains("scope web"));
516 }
517
518 const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
521
522 async fn run_line(args: &[&str]) -> (Result<()>, String) {
523 let mut argv = vec!["scope"];
524 argv.extend_from_slice(args);
525 let cli = Cli::try_parse_from(argv).unwrap();
526 let (o, cap) = Output::capture_merged();
527 let factory = scope::chains::mocks::MockClientFactory::new();
528 let res = dispatch_with(cli.command, &Config::default(), &factory, &o).await;
529 (res, cap.out())
530 }
531
532 #[tokio::test]
533 async fn test_dispatch_with_crawl_by_address() {
534 let (res, out) = run_line(&["crawl", ADDR, "--yes", "--no-charts"]).await;
535 res.unwrap();
536 assert!(!out.trim().is_empty());
537 }
538
539 #[tokio::test]
540 async fn test_dispatch_with_token_health() {
541 let (res, out) = run_line(&["token-health", ADDR]).await;
542 res.unwrap();
543 assert!(!out.trim().is_empty());
544 }
545
546 #[tokio::test]
547 async fn test_dispatch_with_report_batch_writes_the_file() {
548 let dir = tempfile::tempdir().unwrap();
549 let path = dir.path().join("batch.md");
550 let (res, _) = run_line(&[
551 "report",
552 "batch",
553 "--addresses",
554 ADDR,
555 "--output",
556 path.to_str().unwrap(),
557 ])
558 .await;
559 res.unwrap();
560 assert!(std::fs::read_to_string(&path).unwrap().contains(ADDR));
561 }
562
563 #[tokio::test]
564 async fn test_dispatch_with_refuses_the_tui() {
565 let (res, _) = run_line(&["interactive"]).await;
568 assert!(res.unwrap_err().to_string().contains("scope tui"));
569 }
570
571 #[tokio::test]
572 async fn test_compliance_errors_are_labelled_as_compliance_errors() {
573 let dir = tempfile::tempdir().unwrap();
576 let empty = dir.path().join("none.txt");
577 std::fs::write(&empty, "# no addresses\n").unwrap();
578 let out = dir.path().join("report.txt");
579 let (res, _) = run_line(&[
580 "compliance",
581 "compliance-report",
582 empty.to_str().unwrap(),
583 "--jurisdiction",
584 "us",
585 "--output",
586 out.to_str().unwrap(),
587 ])
588 .await;
589 let err = res.unwrap_err();
590 assert!(matches!(err, ScopeError::Compliance(_)), "{err:?}");
591 assert!(err.to_string().contains("No addresses to analyze"));
592
593 let (res, _) = run_line(&["compliance", "risk", "not-an-address"]).await;
594 assert!(matches!(res.unwrap_err(), ScopeError::Compliance(_)));
595 }
596
597 async fn run_offline(args: &[&str], config: &Config) -> (Result<()>, String, String) {
600 let mut argv = vec!["scope"];
601 argv.extend_from_slice(args);
602 let cli = Cli::try_parse_from(argv).unwrap();
603 let (o, cap) = Output::capture();
604 let factory = OfflineFactory::new();
605 let res = dispatch_with(cli.command, config, &factory, &o).await;
606 (res, cap.out(), cap.err())
607 }
608
609 #[tokio::test]
610 async fn test_compliance_resolves_a_saved_label_to_its_address() {
611 let dir = tempfile::tempdir().unwrap();
614 let mut config = Config::default();
615 config.address_book.data_dir = Some(dir.path().to_path_buf());
616 let (res, _, _) = run_offline(
617 &["address-book", "add", ADDR, "--label", "treasury"],
618 &config,
619 )
620 .await;
621 res.unwrap();
622 let (res, out, _) = run_offline(&["address-book", "list"], &config).await;
623 res.unwrap();
624 assert!(out.contains("treasury"), "{out}");
625
626 let (res, out, _) = run_offline(
627 &["compliance", "risk", "@treasury", "--format", "json"],
628 &config,
629 )
630 .await;
631 res.unwrap();
632 let v: serde_json::Value = serde_json::from_str(out.trim()).expect(&out);
633 assert_eq!(v["address"], ADDR, "{out}");
634 assert_eq!(v["chain"], "ethereum", "the saved chain: {out}");
635
636 let (res, _, err) = run_offline(&["compliance", "analyze", "@treasury"], &config).await;
637 res.unwrap();
638 assert!(
639 err.contains(&format!("Analyzing patterns for {ADDR}")),
640 "{err}"
641 );
642
643 let report = dir.path().join("report.json");
644 let (res, _, _) = run_offline(
645 &[
646 "compliance",
647 "compliance-report",
648 "@treasury",
649 "--jurisdiction",
650 "us",
651 "--output",
652 report.to_str().unwrap(),
653 ],
654 &config,
655 )
656 .await;
657 res.unwrap();
658 let text = std::fs::read_to_string(&report).unwrap();
659 assert!(text.contains(ADDR), "{text}");
660 }
661
662 #[tokio::test]
663 async fn test_compliance_trace_runs_its_handler() {
664 let hash = "0x0000000000000000000000000000000000000000000000000000000000000001";
665 let (res, _, err) = run_offline(&["compliance", "trace", hash], &Config::default()).await;
666 match res {
670 Ok(()) => assert!(
671 err.contains(&format!("Tracing transaction {hash}")),
672 "{err}"
673 ),
674 Err(e) => {
675 assert!(matches!(e, ScopeError::Compliance(_)), "{e:?}");
676 assert!(e.to_string().contains("cannot trace transaction"), "{e}");
677 }
678 }
679 }
680
681 #[tokio::test]
682 async fn test_discover_failures_are_labelled_as_discovery_errors() {
683 let (res, _, _) = run_offline(&["discover"], &Config::default()).await;
686 let err = res.unwrap_err();
687 assert!(matches!(err, ScopeError::Discovery(_)), "{err:?}");
688 }
689
690 #[tokio::test]
691 async fn test_dispatch_with_insights_and_contract() {
692 let (res, out, _) = run_offline(&["insights", ADDR], &Config::default()).await;
693 res.unwrap();
694 assert!(!out.trim().is_empty());
695 let (res, out, _) = run_offline(&["contract", ADDR, "--json"], &Config::default()).await;
698 let err = res.unwrap_err();
699 assert!(matches!(err, ScopeError::Chain(_)), "{err:?}");
700 assert!(out.is_empty(), "no report on a failed analysis: {out}");
701 }
702
703 #[test]
704 fn test_create_http_client_selects_the_configured_transport() {
705 let (out, captured) = Output::capture();
706 let client = create_http_client(&Config::default(), &out);
707 assert!(client.is_ok());
708 assert!(captured.err().is_empty(), "{}", captured.err());
709 }
710
711 #[tokio::test]
712 async fn test_compliance_unknown_label_fails_before_any_lookup() {
713 let dir = tempfile::tempdir().unwrap();
716 let mut config = Config::default();
717 config.address_book.data_dir = Some(dir.path().to_path_buf());
718 let factory = scope::chains::mocks::MockClientFactory::new();
719 for args in [
720 vec!["compliance", "risk", "@nobody"],
721 vec!["compliance", "analyze", "@nobody"],
722 vec![
723 "compliance",
724 "compliance-report",
725 "@nobody",
726 "--jurisdiction",
727 "eu",
728 "--output",
729 "unused.txt",
730 ],
731 ] {
732 let mut argv = vec!["scope"];
733 argv.extend_from_slice(&args);
734 let cli = Cli::try_parse_from(argv).unwrap();
735 let (o, cap) = Output::capture_merged();
736 let err = dispatch_with(cli.command, &config, &factory, &o)
737 .await
738 .unwrap_err();
739 assert!(matches!(err, ScopeError::NotFound(_)), "{args:?}: {err:?}");
740 assert!(err.to_string().contains("@nobody"), "{args:?}");
741 assert!(!cap.out().contains("Analyzing"), "handler must not run");
742 }
743 }
744}