1pub mod api;
25pub mod monitor;
26
27use crate::chains::DefaultClientFactory;
28use crate::config::Config;
29use axum::Router;
30use axum::response::IntoResponse;
31use std::net::SocketAddr;
32use std::sync::Arc;
33use tower_http::cors::{Any, CorsLayer};
34
35pub struct AppState {
37 pub config: Config,
39 pub factory: DefaultClientFactory,
41}
42
43pub fn build_router(state: Arc<AppState>) -> Router {
45 let cors = CorsLayer::new()
46 .allow_origin(Any)
47 .allow_methods(Any)
48 .allow_headers(Any);
49
50 let api_routes = api::routes(state.clone());
51
52 Router::new()
53 .nest("/api", api_routes)
54 .route("/ws/monitor", axum::routing::get(monitor::ws_handler))
55 .fallback(axum::routing::get(serve_ui))
56 .layer(cors)
57 .with_state(state)
58}
59
60async fn serve_ui(uri: axum::http::Uri) -> impl axum::response::IntoResponse {
62 let path = uri.path().trim_start_matches('/');
63
64 match path {
66 "" | "index.html" => {
67 axum::response::Html(include_str!("static/index.html")).into_response()
68 }
69 "app.js" => (
70 [(axum::http::header::CONTENT_TYPE, "application/javascript")],
71 include_str!("static/app.js"),
72 )
73 .into_response(),
74 "style.css" => (
75 [(axum::http::header::CONTENT_TYPE, "text/css")],
76 include_str!("static/style.css"),
77 )
78 .into_response(),
79 _ => axum::response::Html(include_str!("static/index.html")).into_response(),
81 }
82}
83
84pub async fn start_server(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
88 let factory = DefaultClientFactory {
89 chains_config: config.chains.clone(),
90 };
91 let state = Arc::new(AppState { config, factory });
92 let app = build_router(state);
93
94 tracing::info!("Scope web server listening on http://{}", addr);
95 eprintln!("Scope web server listening on http://{}", addr);
96
97 let listener = tokio::net::TcpListener::bind(addr).await?;
98 axum::serve(listener, app).await?;
99
100 Ok(())
101}
102
103pub fn pid_file_path() -> std::path::PathBuf {
109 Config::default_data_dir().join("scope-web.pid")
110}
111
112pub fn log_file_path() -> std::path::PathBuf {
114 Config::default_data_dir().join("scope-web.log")
115}
116
117pub fn stop_daemon() -> anyhow::Result<()> {
119 let pid_path = pid_file_path();
120 if !pid_path.exists() {
121 eprintln!("No daemon PID file found at {}", pid_path.display());
122 eprintln!("Is the daemon running?");
123 return Ok(());
124 }
125
126 let pid_str = std::fs::read_to_string(&pid_path)?;
127 let pid: u32 = pid_str
128 .trim()
129 .parse()
130 .map_err(|e| anyhow::anyhow!("Invalid PID in {}: {}", pid_path.display(), e))?;
131
132 eprintln!("Stopping Scope web daemon (PID {})...", pid);
133
134 #[cfg(unix)]
135 {
136 let result = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
138 if result == 0 {
139 eprintln!("Daemon stopped.");
140 } else {
141 eprintln!("Failed to stop daemon (process may have already exited).");
142 }
143 }
144
145 #[cfg(not(unix))]
146 {
147 eprintln!("Daemon stop is only supported on Unix systems.");
148 eprintln!("Please manually terminate PID {}.", pid);
149 }
150
151 let _ = std::fs::remove_file(&pid_path);
153 Ok(())
154}
155
156#[cfg(unix)]
161pub fn start_daemon(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
162 use std::io::Write;
163
164 let _ = config; let data_dir = Config::default_data_dir();
167 std::fs::create_dir_all(&data_dir)?;
168
169 let pid_path = pid_file_path();
170 let log_path = log_file_path();
171
172 eprintln!("Starting Scope web daemon...");
173 eprintln!(" URL: http://{}", addr);
174 eprintln!(" PID: {}", pid_path.display());
175 eprintln!(" Log: {}", log_path.display());
176
177 let log_file = std::fs::OpenOptions::new()
179 .create(true)
180 .append(true)
181 .open(&log_path)?;
182 let log_file_err = log_file.try_clone()?;
183
184 let current_exe = std::env::current_exe()?;
185 let child = std::process::Command::new(current_exe)
186 .args([
187 "web",
188 "--port",
189 &addr.port().to_string(),
190 "--bind",
191 &addr.ip().to_string(),
192 ])
193 .env("SCOPE_WEB_DAEMON_CHILD", "1")
194 .stdout(log_file)
195 .stderr(log_file_err)
196 .stdin(std::process::Stdio::null())
197 .spawn()?;
198
199 let pid = child.id();
200
201 let mut f = std::fs::File::create(&pid_path)?;
203 write!(f, "{}", pid)?;
204
205 eprintln!("Daemon started with PID {}", pid);
206 eprintln!("Stop with: scope web --stop");
207
208 Ok(())
209}
210
211#[cfg(not(unix))]
213pub fn start_daemon(addr: SocketAddr, config: Config) -> anyhow::Result<()> {
214 eprintln!("Daemon mode is only supported on Unix systems.");
215 eprintln!("Starting in foreground instead...");
216 let rt = tokio::runtime::Runtime::new()?;
217 rt.block_on(start_server(addr, config))
218}
219
220pub fn is_daemon_child() -> bool {
222 std::env::var("SCOPE_WEB_DAEMON_CHILD").is_ok()
223}
224
225#[cfg(test)]
230mod tests {
231 use super::*;
232 use axum::body;
233
234 #[test]
235 fn test_pid_file_path() {
236 let path = pid_file_path();
237 assert!(path.to_string_lossy().contains("scope-web.pid"));
238 }
239
240 #[test]
241 fn test_log_file_path() {
242 let path = log_file_path();
243 assert!(path.to_string_lossy().contains("scope-web.log"));
244 }
245
246 #[test]
247 fn test_build_router() {
248 let config = Config::default();
249 let factory = DefaultClientFactory {
250 chains_config: config.chains.clone(),
251 };
252 let state = Arc::new(AppState { config, factory });
253 let _router = build_router(state);
254 }
255
256 #[test]
257 fn test_is_daemon_child_default() {
258 let _ = is_daemon_child();
261 }
262
263 #[tokio::test]
264 async fn test_serve_ui_index() {
265 let response = serve_ui(axum::http::Uri::from_static("/"))
266 .await
267 .into_response();
268 assert_eq!(response.status(), axum::http::StatusCode::OK);
269 let body = body::to_bytes(response.into_body(), 1_000_000)
270 .await
271 .unwrap();
272 let html = String::from_utf8(body.to_vec()).unwrap();
273 assert!(html.contains("<!DOCTYPE html>"));
274 assert!(html.contains("Scope"));
275 }
276
277 #[tokio::test]
278 async fn test_serve_ui_index_html() {
279 let response = serve_ui(axum::http::Uri::from_static("/index.html"))
280 .await
281 .into_response();
282 assert_eq!(response.status(), axum::http::StatusCode::OK);
283 }
284
285 #[tokio::test]
286 async fn test_serve_ui_app_js() {
287 let response = serve_ui(axum::http::Uri::from_static("/app.js"))
288 .await
289 .into_response();
290 assert_eq!(response.status(), axum::http::StatusCode::OK);
291 let body = body::to_bytes(response.into_body(), 1_000_000)
292 .await
293 .unwrap();
294 let js = String::from_utf8(body.to_vec()).unwrap();
295 assert!(js.contains("function") || js.contains("const") || js.contains("var"));
296 }
297
298 #[tokio::test]
299 async fn test_serve_ui_style_css() {
300 let response = serve_ui(axum::http::Uri::from_static("/style.css"))
301 .await
302 .into_response();
303 assert_eq!(response.status(), axum::http::StatusCode::OK);
304 let body = body::to_bytes(response.into_body(), 1_000_000)
305 .await
306 .unwrap();
307 let css = String::from_utf8(body.to_vec()).unwrap();
308 assert!(css.contains(":root") || css.contains("body") || css.contains("nav"));
309 }
310
311 #[tokio::test]
312 async fn test_serve_ui_spa_fallback() {
313 let response = serve_ui(axum::http::Uri::from_static("/some/random/path"))
315 .await
316 .into_response();
317 assert_eq!(response.status(), axum::http::StatusCode::OK);
318 let body = body::to_bytes(response.into_body(), 1_000_000)
319 .await
320 .unwrap();
321 let html = String::from_utf8(body.to_vec()).unwrap();
322 assert!(html.contains("<!DOCTYPE html>"));
323 }
324
325 #[test]
326 fn test_pid_file_path_in_data_dir() {
327 let path = pid_file_path();
328 assert!(path.ends_with("scope-web.pid"));
329 assert!(path.parent().is_some());
330 }
331
332 #[test]
333 fn test_log_file_path_in_data_dir() {
334 let path = log_file_path();
335 assert!(path.ends_with("scope-web.log"));
336 assert!(path.parent().is_some());
337 }
338
339 #[test]
340 fn test_app_state_construction() {
341 let config = Config::default();
342 let factory = DefaultClientFactory {
343 chains_config: config.chains.clone(),
344 };
345 let state = AppState { config, factory };
346 let _ = state.config.chains.api_keys.len();
348 }
349
350 #[test]
351 fn test_stop_daemon_no_pid_file() {
352 let pid = pid_file_path();
356 let log = log_file_path();
357 assert_eq!(pid.parent(), log.parent());
358 }
359
360 fn test_state() -> Arc<AppState> {
362 let config = Config::default();
363 let factory = DefaultClientFactory {
364 chains_config: config.chains.clone(),
365 };
366 Arc::new(AppState { config, factory })
367 }
368
369 #[tokio::test]
374 async fn test_route_get_config_status() {
375 use axum::http::{Request, StatusCode};
376 use tower::ServiceExt;
377
378 let state = test_state();
379 let app = build_router(state);
380
381 let req = Request::builder()
382 .uri("/api/config/status")
383 .method("GET")
384 .body(axum::body::Body::empty())
385 .unwrap();
386
387 let response = app.oneshot(req).await.unwrap();
388 assert_eq!(response.status(), StatusCode::OK);
389
390 let body = body::to_bytes(response.into_body(), 1_000_000)
391 .await
392 .unwrap();
393 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
394 assert!(json.get("config_path").is_some());
395 assert!(json.get("api_keys").is_some());
396 assert!(json.get("version").is_some());
397 }
398
399 #[tokio::test]
400 async fn test_route_post_config_save() {
401 use axum::http::{Request, StatusCode, header};
402 use tower::ServiceExt;
403
404 let state = test_state();
405 let app = build_router(state);
406
407 let payload = serde_json::json!({
408 "api_keys": {},
409 "rpc_endpoints": {}
410 });
411
412 let req = Request::builder()
413 .uri("/api/config")
414 .method("POST")
415 .header(header::CONTENT_TYPE, "application/json")
416 .body(axum::body::Body::from(payload.to_string()))
417 .unwrap();
418
419 let response = app.oneshot(req).await.unwrap();
420 let status = response.status();
422 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
423 }
424
425 #[tokio::test]
426 async fn test_route_post_address() {
427 use axum::http::{Request, StatusCode, header};
428 use tower::ServiceExt;
429
430 let state = test_state();
431 let app = build_router(state);
432
433 let payload = serde_json::json!({
434 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
435 "chain": "ethereum"
436 });
437
438 let req = Request::builder()
439 .uri("/api/address")
440 .method("POST")
441 .header(header::CONTENT_TYPE, "application/json")
442 .body(axum::body::Body::from(payload.to_string()))
443 .unwrap();
444
445 let response = app.oneshot(req).await.unwrap();
446 let status = response.status();
448 assert!(
449 status == StatusCode::OK
450 || status == StatusCode::BAD_REQUEST
451 || status == StatusCode::INTERNAL_SERVER_ERROR
452 );
453 }
454
455 #[tokio::test]
456 async fn test_route_post_tx() {
457 use axum::http::{Request, StatusCode, header};
458 use tower::ServiceExt;
459
460 let state = test_state();
461 let app = build_router(state);
462
463 let payload = serde_json::json!({
464 "hash": "0xabc123def456789012345678901234567890123456789012345678901234abcd",
465 "chain": "ethereum"
466 });
467
468 let req = Request::builder()
469 .uri("/api/tx")
470 .method("POST")
471 .header(header::CONTENT_TYPE, "application/json")
472 .body(axum::body::Body::from(payload.to_string()))
473 .unwrap();
474
475 let response = app.oneshot(req).await.unwrap();
476 let status = response.status();
477 assert!(
478 status == StatusCode::OK
479 || status == StatusCode::BAD_REQUEST
480 || status == StatusCode::INTERNAL_SERVER_ERROR
481 );
482 }
483
484 #[tokio::test]
485 async fn test_route_post_crawl() {
486 use axum::http::{Request, StatusCode, header};
487 use tower::ServiceExt;
488
489 let state = test_state();
490 let app = build_router(state);
491
492 let payload = serde_json::json!({
493 "token": "USDC",
494 "chain": "ethereum"
495 });
496
497 let req = Request::builder()
498 .uri("/api/crawl")
499 .method("POST")
500 .header(header::CONTENT_TYPE, "application/json")
501 .body(axum::body::Body::from(payload.to_string()))
502 .unwrap();
503
504 let response = app.oneshot(req).await.unwrap();
505 let status = response.status();
506 assert!(
507 status == StatusCode::OK
508 || status == StatusCode::BAD_REQUEST
509 || status == StatusCode::INTERNAL_SERVER_ERROR
510 );
511 }
512
513 #[tokio::test]
514 async fn test_route_get_discover() {
515 use axum::http::{Request, StatusCode};
516 use tower::ServiceExt;
517
518 let state = test_state();
519 let app = build_router(state);
520
521 let req = Request::builder()
522 .uri("/api/discover?source=profiles&limit=5")
523 .method("GET")
524 .body(axum::body::Body::empty())
525 .unwrap();
526
527 let response = app.oneshot(req).await.unwrap();
528 let status = response.status();
529 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
530 }
531
532 #[tokio::test]
533 async fn test_route_post_insights() {
534 use axum::http::{Request, StatusCode, header};
535 use tower::ServiceExt;
536
537 let state = test_state();
538 let app = build_router(state);
539
540 let payload = serde_json::json!({
541 "target": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
542 });
543
544 let req = Request::builder()
545 .uri("/api/insights")
546 .method("POST")
547 .header(header::CONTENT_TYPE, "application/json")
548 .body(axum::body::Body::from(payload.to_string()))
549 .unwrap();
550
551 let response = app.oneshot(req).await.unwrap();
552 let status = response.status();
553 assert!(
554 status == StatusCode::OK
555 || status == StatusCode::BAD_REQUEST
556 || status == StatusCode::INTERNAL_SERVER_ERROR
557 );
558 }
559
560 #[tokio::test]
561 async fn test_route_post_compliance_risk() {
562 use axum::http::{Request, StatusCode, header};
563 use tower::ServiceExt;
564
565 let state = test_state();
566 let app = build_router(state);
567
568 let payload = serde_json::json!({
569 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
570 "chain": "ethereum",
571 "detailed": true
572 });
573
574 let req = Request::builder()
575 .uri("/api/compliance/risk")
576 .method("POST")
577 .header(header::CONTENT_TYPE, "application/json")
578 .body(axum::body::Body::from(payload.to_string()))
579 .unwrap();
580
581 let response = app.oneshot(req).await.unwrap();
582 let status = response.status();
583 assert!(
584 status == StatusCode::OK
585 || status == StatusCode::BAD_REQUEST
586 || status == StatusCode::INTERNAL_SERVER_ERROR
587 );
588 }
589
590 #[tokio::test]
591 async fn test_route_post_export() {
592 use axum::http::{Request, StatusCode, header};
593 use tower::ServiceExt;
594
595 let state = test_state();
596 let app = build_router(state);
597
598 let payload = serde_json::json!({
599 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
600 "chain": "ethereum",
601 "format": "json"
602 });
603
604 let req = Request::builder()
605 .uri("/api/export")
606 .method("POST")
607 .header(header::CONTENT_TYPE, "application/json")
608 .body(axum::body::Body::from(payload.to_string()))
609 .unwrap();
610
611 let response = app.oneshot(req).await.unwrap();
612 let status = response.status();
613 assert!(
614 status == StatusCode::OK
615 || status == StatusCode::BAD_REQUEST
616 || status == StatusCode::INTERNAL_SERVER_ERROR
617 );
618 }
619
620 #[tokio::test]
621 async fn test_route_post_token_health() {
622 use axum::http::{Request, StatusCode, header};
623 use tower::ServiceExt;
624
625 let state = test_state();
626 let app = build_router(state);
627
628 let payload = serde_json::json!({
629 "token": "USDC",
630 "chain": "ethereum",
631 "with_market": false
632 });
633
634 let req = Request::builder()
635 .uri("/api/token-health")
636 .method("POST")
637 .header(header::CONTENT_TYPE, "application/json")
638 .body(axum::body::Body::from(payload.to_string()))
639 .unwrap();
640
641 let response = app.oneshot(req).await.unwrap();
642 let status = response.status();
643 assert!(
644 status == StatusCode::OK
645 || status == StatusCode::BAD_REQUEST
646 || status == StatusCode::INTERNAL_SERVER_ERROR
647 );
648 }
649
650 #[tokio::test]
651 async fn test_route_post_market_summary() {
652 use axum::http::{Request, StatusCode, header};
653 use tower::ServiceExt;
654
655 let state = test_state();
656 let app = build_router(state);
657
658 let payload = serde_json::json!({
659 "pair": "USDC",
660 "market_venue": "binance"
661 });
662
663 let req = Request::builder()
664 .uri("/api/market/summary")
665 .method("POST")
666 .header(header::CONTENT_TYPE, "application/json")
667 .body(axum::body::Body::from(payload.to_string()))
668 .unwrap();
669
670 let response = app.oneshot(req).await.unwrap();
671 let status = response.status();
672 assert!(
673 status == StatusCode::OK
674 || status == StatusCode::BAD_REQUEST
675 || status == StatusCode::INTERNAL_SERVER_ERROR
676 );
677 }
678
679 #[tokio::test]
680 async fn test_route_get_address_book_list() {
681 use axum::http::{Request, StatusCode};
682 use tower::ServiceExt;
683
684 let state = test_state();
685 let app = build_router(state);
686
687 let req = Request::builder()
688 .uri("/api/address-book/list")
689 .method("GET")
690 .body(axum::body::Body::empty())
691 .unwrap();
692
693 let response = app.oneshot(req).await.unwrap();
694 let status = response.status();
695 assert!(status == StatusCode::OK || status == StatusCode::INTERNAL_SERVER_ERROR);
696 }
697
698 #[tokio::test]
699 async fn test_route_post_address_book_add() {
700 use axum::http::{Request, StatusCode, header};
701 use tower::ServiceExt;
702
703 let state = test_state();
704 let app = build_router(state);
705
706 let payload = serde_json::json!({
707 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
708 "chain": "ethereum",
709 "label": "Test Wallet"
710 });
711
712 let req = Request::builder()
713 .uri("/api/address-book/add")
714 .method("POST")
715 .header(header::CONTENT_TYPE, "application/json")
716 .body(axum::body::Body::from(payload.to_string()))
717 .unwrap();
718
719 let response = app.oneshot(req).await.unwrap();
720 let status = response.status();
721 assert!(
722 status == StatusCode::OK
723 || status == StatusCode::BAD_REQUEST
724 || status == StatusCode::INTERNAL_SERVER_ERROR
725 );
726 }
727
728 #[tokio::test]
729 async fn test_route_invalid_json_body() {
730 use axum::http::{Request, header};
731 use tower::ServiceExt;
732
733 let state = test_state();
734 let app = build_router(state);
735
736 let req = Request::builder()
737 .uri("/api/address")
738 .method("POST")
739 .header(header::CONTENT_TYPE, "application/json")
740 .body(axum::body::Body::from("not json"))
741 .unwrap();
742
743 let response = app.oneshot(req).await.unwrap();
744 assert!(response.status().is_client_error());
746 }
747
748 #[tokio::test]
749 async fn test_route_missing_required_field() {
750 use axum::http::{Request, header};
751 use tower::ServiceExt;
752
753 let state = test_state();
754 let app = build_router(state);
755
756 let payload = serde_json::json!({ "chain": "ethereum" });
758
759 let req = Request::builder()
760 .uri("/api/address")
761 .method("POST")
762 .header(header::CONTENT_TYPE, "application/json")
763 .body(axum::body::Body::from(payload.to_string()))
764 .unwrap();
765
766 let response = app.oneshot(req).await.unwrap();
767 assert!(response.status().is_client_error());
768 }
769
770 #[tokio::test]
771 async fn test_route_static_fallback() {
772 use axum::http::{Request, StatusCode};
773 use tower::ServiceExt;
774
775 let state = test_state();
776 let app = build_router(state);
777
778 let req = Request::builder()
779 .uri("/nonexistent-path")
780 .method("GET")
781 .body(axum::body::Body::empty())
782 .unwrap();
783
784 let response = app.oneshot(req).await.unwrap();
785 assert_eq!(response.status(), StatusCode::OK);
786 }
787}