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 if needs_venues(&command) {
138 crate::cli::venues::ensure_catalog(config, clients.http().as_ref(), out).await;
139 }
140 match command {
141 Commands::Completions(args) => {
142 let mut cmd = Cli::command();
143 let mut buf = Vec::new();
144 generate(args.shell, &mut cmd, "scope", &mut buf);
145 out!(out, "{}", String::from_utf8_lossy(&buf))?;
146 Ok(())
147 }
148 Commands::Address(args) => crate::cli::address::run(args, config, clients, out).await,
149 Commands::Tx(args) => crate::cli::tx::run(args, config, clients, out).await,
150 Commands::Crawl(args) => crate::cli::crawl::run(args, config, clients, out).await,
151 Commands::AddressBook(args) => {
152 crate::cli::address_book::run(args, config, clients, out).await
153 }
154 Commands::Export(args) => crate::cli::export::run(args, config, clients, out).await,
155 Commands::Interactive(_) => Err(ScopeError::Other(
158 "the TUI cannot start from here; start it with `scope tui`".into(),
159 )),
160 Commands::Monitor(args) => {
161 crate::cli::monitor::run_direct(args, config, clients, out).await
162 }
163 Commands::Setup(args) => crate::cli::setup::run(args, config, out).await,
164 Commands::Compliance(compliance_cmd) => {
165 dispatch_compliance(compliance_cmd, config, clients.http(), out).await
166 }
167 Commands::Market(cmd) => crate::cli::market::run(cmd, config, clients, out).await,
168 Commands::TokenHealth(args) => {
169 crate::cli::token_health::run(args, config, clients, out).await
170 }
171 Commands::Venues(cmd) => crate::cli::venues::run(cmd, config, clients.http(), out).await,
172 Commands::Report(cmd) => crate::cli::report::run(cmd, config, clients, out).await,
173 Commands::Discover(args) => {
174 crate::cli::discover::run(args, config.output.format, clients.http(), out)
175 .await
176 .map_err(|e| match e {
177 ScopeError::IoError(_) => e,
179 other => ScopeError::Discovery(other.to_string()),
180 })
181 }
182 Commands::Insights(args) => crate::cli::insights::run(args, config, clients, out).await,
183 Commands::Contract(ref args) => crate::cli::contract::run(args, config, clients, out).await,
184 Commands::Web(_) => Err(ScopeError::Other(
185 "the web server cannot run from here; start it with `scope web`".into(),
186 )),
187 }
188}
189
190fn needs_venues(command: &Commands) -> bool {
193 use crate::cli::venues::VenuesCommands;
194 match command {
195 Commands::Market(_)
196 | Commands::Monitor(_)
197 | Commands::TokenHealth(_)
198 | Commands::Insights(_) => true,
199 Commands::Venues(cmd) => matches!(cmd, VenuesCommands::List(_)),
200 _ => false,
201 }
202}
203
204async fn dispatch_compliance(
207 cmd: crate::cli::compliance::ComplianceCommands,
208 config: &Config,
209 http: Arc<dyn HttpClient>,
210 out: &Output,
211) -> Result<()> {
212 use crate::cli::address_book::resolve_address_book_input;
213 use crate::cli::compliance::{self, ComplianceCommands};
214
215 let result = match cmd {
216 ComplianceCommands::Risk(mut args) => {
217 if let Some((addr, chain)) = resolve_address_book_input(&args.address, config)? {
218 args.address = addr;
219 if args.chain.is_none() {
220 args.chain = Some(chain);
221 }
222 }
223 compliance::handle_risk(args, http, out).await
224 }
225 ComplianceCommands::Trace(args) => compliance::handle_trace(args, http, out).await,
226 ComplianceCommands::Analyze(mut args) => {
227 if let Some((addr, _chain)) = resolve_address_book_input(&args.address, config)? {
228 args.address = addr;
229 }
230 compliance::handle_analyze(args, http, out).await
231 }
232 ComplianceCommands::ComplianceReport(mut args) => {
233 if !std::path::Path::new(&args.target).exists()
234 && let Some((addr, _chain)) = resolve_address_book_input(&args.target, config)?
235 {
236 args.target = addr;
237 }
238 compliance::handle_compliance_report(args, http, out).await
239 }
240 };
241 result.map_err(|e| from_anyhow(e, ScopeError::Compliance))
242}
243
244fn from_anyhow(e: anyhow::Error, kind: fn(String) -> ScopeError) -> ScopeError {
248 match e.downcast::<std::io::Error>() {
249 Ok(io) => ScopeError::IoError(io),
250 Err(e) => match e.downcast::<ScopeError>() {
251 Ok(scope) => scope,
252 Err(e) => kind(e.to_string()),
253 },
254 }
255}
256
257pub fn create_http_client(config: &Config, out: &Output) -> Result<Arc<dyn HttpClient>> {
264 create_http_client_with(config, out, &|k| std::env::var(k).ok())
265}
266
267fn create_http_client_with(
269 config: &Config,
270 out: &Output,
271 env: &dyn Fn(&str) -> Option<String>,
272) -> Result<Arc<dyn HttpClient>> {
273 create_http_client_offering(config, out, env, false)
274}
275
276fn create_http_client_offering(
280 config: &Config,
281 out: &Output,
282 env: &dyn Fn(&str) -> Option<String>,
283 offer: bool,
284) -> Result<Arc<dyn HttpClient>> {
285 let mut built = scope::http::shared::build_with(config, env)?;
286 if offer && built.ghola_missing && crate::cli::ghola_offer::offer_for_transport(out).is_some() {
287 built = scope::http::shared::build_with(config, env)?;
288 }
289 for notice in &built.notices {
290 note!(out, "{notice}");
291 }
292 Ok(built.client)
293}
294
295fn transport_for_reload(
300 config: &Config,
301 current: &Arc<dyn HttpClient>,
302 out: &Output,
303 env: &dyn Fn(&str) -> Option<String>,
304) -> Result<Arc<dyn HttpClient>> {
305 use scope::http::shared::{Kind, configured_kind_with};
306 match configured_kind_with(config, env) {
307 Kind::Replay | Kind::Record => Ok(current.clone()),
308 Kind::Native | Kind::Ghola => create_http_client_with(config, out, env),
309 }
310}
311
312#[cfg(test)]
317pub(crate) mod tests {
318 pub(crate) struct OfflineFactory {
324 pub(crate) mock: scope::chains::mocks::MockClientFactory,
325 http: std::sync::Arc<dyn scope::http::HttpClient>,
326 _fixtures: tempfile::TempDir,
327 }
328
329 impl OfflineFactory {
330 pub(crate) fn new() -> Self {
331 let fixtures = tempfile::tempdir().unwrap();
332 Self {
333 mock: scope::chains::mocks::MockClientFactory::new(),
334 http: std::sync::Arc::new(scope::http::replay::ReplayHttpClient::replay(
335 fixtures.path().to_path_buf(),
336 )),
337 _fixtures: fixtures,
338 }
339 }
340 }
341
342 impl scope::chains::ChainClientFactory for OfflineFactory {
343 fn create_chain_client(
344 &self,
345 chain: &str,
346 ) -> scope::error::Result<Box<dyn scope::chains::ChainClient>> {
347 self.mock.create_chain_client(chain)
348 }
349
350 fn create_dex_client(&self) -> Box<dyn scope::chains::DexDataSource> {
351 self.mock.create_dex_client()
352 }
353
354 fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
355 self.http.clone()
356 }
357 }
358
359 #[test]
360 fn test_a_config_source_reads_its_file_and_applies_ai() {
361 use super::ConfigSource;
362 use scope::config::OutputFormat;
363 let dir = tempfile::tempdir().unwrap();
364 let path = dir.path().join("c.yaml");
365 std::fs::write(&path, "output:\n format: json\n").unwrap();
366 let plain = ConfigSource {
367 path: Some(path.clone()),
368 ai: false,
369 };
370 assert_eq!(plain.load().unwrap().output.format, OutputFormat::Json);
371 let ai = ConfigSource {
372 path: Some(path),
373 ai: true,
374 };
375 assert_eq!(ai.load().unwrap().output.format, OutputFormat::Markdown);
376 }
377
378 #[test]
379 fn test_anyhow_io_errors_keep_their_kind() {
380 let pipe = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "closed");
383 let e = super::from_anyhow(pipe.into(), ScopeError::Compliance);
384 assert!(
385 matches!(&e, ScopeError::IoError(io) if io.kind() == std::io::ErrorKind::BrokenPipe)
386 );
387 let scope = super::from_anyhow(
388 ScopeError::NotFound("x".into()).into(),
389 ScopeError::Compliance,
390 );
391 assert!(matches!(scope, ScopeError::NotFound(_)));
392 let other = super::from_anyhow(anyhow::anyhow!("bad"), ScopeError::Compliance);
393 assert_eq!(
394 other.to_string(),
395 ScopeError::Compliance("bad".into()).to_string()
396 );
397 }
398
399 use super::*;
400 use clap::Parser;
401 use scope::http::shared::Kind;
402
403 fn quiet() -> Output {
404 Output::capture().0
405 }
406
407 #[test]
408 fn test_create_http_client_builds_a_new_transport_each_call() {
409 let config = Config::default();
411 let (out, captured) = Output::capture();
412 let a = create_http_client_with(&config, &out, &|_| None).unwrap();
413 let b = create_http_client_with(&config, &out, &|_| None).unwrap();
414 assert!(!Arc::ptr_eq(&a, &b));
415 assert!(captured.err().is_empty(), "{}", captured.err());
417 }
418
419 #[test]
420 fn test_a_reload_builds_the_transport_again() {
421 let (out, _) = Output::capture();
423 let current = create_http_client_with(&Config::default(), &out, &|_| None).unwrap();
424 let next = transport_for_reload(&Config::default(), ¤t, &out, &|_| None).unwrap();
425 assert!(!Arc::ptr_eq(¤t, &next));
426 }
427
428 #[test]
429 fn test_a_reload_keeps_the_replay_and_record_transport() {
430 let (out, _) = Output::capture();
433 let current = create_http_client_with(&Config::default(), &out, &|_| None).unwrap();
434 for mode in ["replay", "record"] {
435 let env = move |k: &str| match k {
436 "SCOPE_HTTP_MODE" => Some(mode.to_string()),
437 "SCOPE_HTTP_FIXTURES" => Some("unused".to_string()),
438 _ => None,
439 };
440 let next = transport_for_reload(&Config::default(), ¤t, &out, &env).unwrap();
441 assert!(Arc::ptr_eq(¤t, &next), "{mode}");
442 }
443 }
444
445 fn assert_ghola_selection(stealth: bool) {
449 let mut config = Config::default();
450 config.ghola.enabled = true;
451 config.ghola.stealth = stealth;
452 let built = scope::http::shared::build_with(&config, &|_| None).unwrap();
453 match built.kind {
454 Kind::Ghola => assert!(built.notices.is_empty(), "{:?}", built.notices),
455 Kind::Native => {
456 let all = built.notices.join("\n");
457 assert!(all.contains("Ghola"), "{all}");
458 assert!(all.contains("Falling back"), "{all}");
459 }
460 other => panic!("unexpected kind {other:?}"),
461 }
462 }
463
464 #[test]
465 fn test_transport_selection_default_config_is_native() {
466 let built = scope::http::shared::build_with(&Config::default(), &|_| None).unwrap();
467 assert_eq!(built.kind, Kind::Native);
468 assert!(built.notices.is_empty(), "{:?}", built.notices);
469 }
470
471 #[test]
472 fn test_transport_selection_ghola_enabled() {
473 assert_ghola_selection(true);
474 }
475
476 #[test]
477 fn test_transport_selection_ghola_no_stealth() {
478 assert_ghola_selection(false);
479 }
480
481 #[tokio::test]
482 async fn test_dispatch_writes_completions_to_output() {
483 let cli = Cli::try_parse_from(["scope", "completions", "bash"]).unwrap();
486 let (o, cap) = Output::capture();
487 dispatch(
488 cli.command,
489 &ConfigSource::default(),
490 &Config::default(),
491 &o,
492 )
493 .await
494 .unwrap();
495 assert!(cap.out().contains("_scope()"));
496 }
497
498 #[tokio::test]
499 async fn test_dispatch_fails_when_output_cannot_be_written() {
500 let cli = Cli::try_parse_from(["scope", "completions", "zsh"]).unwrap();
503 let o = Output::from_writers(
504 crate::cli::output::tests::ClosedPipe,
505 crate::cli::output::tests::ClosedPipe,
506 );
507 let err = dispatch(
508 cli.command,
509 &ConfigSource::default(),
510 &Config::default(),
511 &o,
512 )
513 .await
514 .unwrap_err();
515 assert!(
516 matches!(err, ScopeError::IoError(ref e) if e.kind() == std::io::ErrorKind::BrokenPipe)
517 );
518 }
519
520 #[tokio::test]
521 async fn test_dispatch_rejects_web() {
522 let cli = Cli::try_parse_from(["scope", "web"]).unwrap();
524 let err = dispatch(
525 cli.command,
526 &ConfigSource::default(),
527 &Config::default(),
528 &quiet(),
529 )
530 .await
531 .unwrap_err();
532 assert!(err.to_string().contains("scope web"));
533 }
534
535 const ADDR: &str = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2";
538
539 async fn run_line(args: &[&str]) -> (Result<()>, String) {
540 let mut argv = vec!["scope"];
541 argv.extend_from_slice(args);
542 let cli = Cli::try_parse_from(argv).unwrap();
543 let (o, cap) = Output::capture_merged();
544 let factory = scope::chains::mocks::MockClientFactory::new();
545 let res = dispatch_with(cli.command, &Config::default(), &factory, &o).await;
546 (res, cap.out())
547 }
548
549 #[tokio::test]
550 async fn test_dispatch_with_crawl_by_address() {
551 let (res, out) = run_line(&["crawl", ADDR, "--yes", "--no-charts"]).await;
552 res.unwrap();
553 assert!(!out.trim().is_empty());
554 }
555
556 #[tokio::test]
557 async fn test_dispatch_with_token_health() {
558 let (res, out) = run_line(&["token-health", ADDR]).await;
559 res.unwrap();
560 assert!(!out.trim().is_empty());
561 }
562
563 #[tokio::test]
564 async fn test_dispatch_with_report_batch_writes_the_file() {
565 let dir = tempfile::tempdir().unwrap();
566 let path = dir.path().join("batch.md");
567 let (res, _) = run_line(&[
568 "report",
569 "batch",
570 "--addresses",
571 ADDR,
572 "--output",
573 path.to_str().unwrap(),
574 ])
575 .await;
576 res.unwrap();
577 assert!(std::fs::read_to_string(&path).unwrap().contains(ADDR));
578 }
579
580 #[tokio::test]
581 async fn test_dispatch_with_refuses_the_tui() {
582 let (res, _) = run_line(&["interactive"]).await;
585 assert!(res.unwrap_err().to_string().contains("scope tui"));
586 }
587
588 #[tokio::test]
589 async fn test_compliance_errors_are_labelled_as_compliance_errors() {
590 let dir = tempfile::tempdir().unwrap();
593 let empty = dir.path().join("none.txt");
594 std::fs::write(&empty, "# no addresses\n").unwrap();
595 let out = dir.path().join("report.txt");
596 let (res, _) = run_line(&[
597 "compliance",
598 "compliance-report",
599 empty.to_str().unwrap(),
600 "--jurisdiction",
601 "us",
602 "--output",
603 out.to_str().unwrap(),
604 ])
605 .await;
606 let err = res.unwrap_err();
607 assert!(matches!(err, ScopeError::Compliance(_)), "{err:?}");
608 assert!(err.to_string().contains("No addresses to analyze"));
609
610 let (res, _) = run_line(&["compliance", "risk", "not-an-address"]).await;
611 assert!(matches!(res.unwrap_err(), ScopeError::Compliance(_)));
612 }
613
614 async fn run_offline(args: &[&str], config: &Config) -> (Result<()>, String, String) {
617 let mut argv = vec!["scope"];
618 argv.extend_from_slice(args);
619 let cli = Cli::try_parse_from(argv).unwrap();
620 let (o, cap) = Output::capture();
621 let factory = OfflineFactory::new();
622 let res = dispatch_with(cli.command, config, &factory, &o).await;
623 (res, cap.out(), cap.err())
624 }
625
626 #[tokio::test]
627 async fn test_compliance_resolves_a_saved_label_to_its_address() {
628 let dir = tempfile::tempdir().unwrap();
631 let mut config = Config::default();
632 config.address_book.data_dir = Some(dir.path().to_path_buf());
633 let (res, _, _) = run_offline(
634 &["address-book", "add", ADDR, "--label", "treasury"],
635 &config,
636 )
637 .await;
638 res.unwrap();
639 let (res, out, _) = run_offline(&["address-book", "list"], &config).await;
640 res.unwrap();
641 assert!(out.contains("treasury"), "{out}");
642
643 let (res, out, _) = run_offline(
644 &["compliance", "risk", "@treasury", "--format", "json"],
645 &config,
646 )
647 .await;
648 res.unwrap();
649 let v: serde_json::Value = serde_json::from_str(out.trim()).expect(&out);
650 assert_eq!(v["address"], ADDR, "{out}");
651 assert_eq!(v["chain"], "ethereum", "the saved chain: {out}");
652
653 let (res, _, err) = run_offline(&["compliance", "analyze", "@treasury"], &config).await;
654 res.unwrap();
655 assert!(
656 err.contains(&format!("Analyzing patterns for {ADDR}")),
657 "{err}"
658 );
659
660 let report = dir.path().join("report.json");
661 let (res, _, _) = run_offline(
662 &[
663 "compliance",
664 "compliance-report",
665 "@treasury",
666 "--jurisdiction",
667 "us",
668 "--output",
669 report.to_str().unwrap(),
670 ],
671 &config,
672 )
673 .await;
674 res.unwrap();
675 let text = std::fs::read_to_string(&report).unwrap();
676 assert!(text.contains(ADDR), "{text}");
677 }
678
679 #[tokio::test]
680 async fn test_compliance_trace_runs_its_handler() {
681 let hash = "0x0000000000000000000000000000000000000000000000000000000000000001";
682 let (res, _, err) = run_offline(&["compliance", "trace", hash], &Config::default()).await;
683 match res {
687 Ok(()) => assert!(
688 err.contains(&format!("Tracing transaction {hash}")),
689 "{err}"
690 ),
691 Err(e) => {
692 assert!(matches!(e, ScopeError::Compliance(_)), "{e:?}");
693 assert!(e.to_string().contains("cannot trace transaction"), "{e}");
694 }
695 }
696 }
697
698 #[tokio::test]
699 async fn test_discover_failures_are_labelled_as_discovery_errors() {
700 let (res, _, _) = run_offline(&["discover"], &Config::default()).await;
703 let err = res.unwrap_err();
704 assert!(matches!(err, ScopeError::Discovery(_)), "{err:?}");
705 }
706
707 #[tokio::test]
708 async fn test_dispatch_with_insights_and_contract() {
709 let (res, out, _) = run_offline(&["insights", ADDR], &Config::default()).await;
710 res.unwrap();
711 assert!(!out.trim().is_empty());
712 let (res, out, _) = run_offline(&["contract", ADDR, "--json"], &Config::default()).await;
715 let err = res.unwrap_err();
716 assert!(matches!(err, ScopeError::Chain(_)), "{err:?}");
717 assert!(out.is_empty(), "no report on a failed analysis: {out}");
718 }
719
720 #[test]
721 fn test_create_http_client_selects_the_configured_transport() {
722 let (out, captured) = Output::capture();
723 let client = create_http_client(&Config::default(), &out);
724 assert!(client.is_ok());
725 assert!(captured.err().is_empty(), "{}", captured.err());
726 }
727
728 #[tokio::test]
729 async fn test_compliance_unknown_label_fails_before_any_lookup() {
730 let dir = tempfile::tempdir().unwrap();
733 let mut config = Config::default();
734 config.address_book.data_dir = Some(dir.path().to_path_buf());
735 let factory = scope::chains::mocks::MockClientFactory::new();
736 for args in [
737 vec!["compliance", "risk", "@nobody"],
738 vec!["compliance", "analyze", "@nobody"],
739 vec![
740 "compliance",
741 "compliance-report",
742 "@nobody",
743 "--jurisdiction",
744 "eu",
745 "--output",
746 "unused.txt",
747 ],
748 ] {
749 let mut argv = vec!["scope"];
750 argv.extend_from_slice(&args);
751 let cli = Cli::try_parse_from(argv).unwrap();
752 let (o, cap) = Output::capture_merged();
753 let err = dispatch_with(cli.command, &config, &factory, &o)
754 .await
755 .unwrap_err();
756 assert!(matches!(err, ScopeError::NotFound(_)), "{args:?}: {err:?}");
757 assert!(err.to_string().contains("@nobody"), "{args:?}");
758 assert!(!cap.out().contains("Analyzing"), "handler must not run");
759 }
760 }
761}