Skip to main content

scope/web/api/
mod.rs

1//! # Web API Handlers
2//!
3//! REST API endpoints mirroring CLI commands. Each handler accepts JSON
4//! request bodies matching CLI argument structures and returns JSON responses.
5
6pub mod address;
7pub mod address_book;
8pub mod compliance;
9pub mod config_status;
10pub mod contract;
11pub mod crawl;
12pub mod discover;
13pub mod exchange;
14pub mod export;
15pub mod insights;
16pub mod market;
17pub mod token_health;
18pub mod tx;
19pub mod venues;
20
21use crate::web::AppState;
22use axum::Router;
23use std::sync::Arc;
24
25/// Result of resolving a user input through the address book.
26///
27/// When the input matches an address book entry (either via `@label`
28/// prefix or direct address match), both the resolved address and chain
29/// are returned. Handlers should use the resolved chain when the user
30/// hasn't explicitly overridden it.
31#[derive(Debug)]
32pub struct ResolvedInput {
33    /// The resolved address/token string.
34    pub value: String,
35    /// The chain from the address book entry (if resolved).
36    pub chain: Option<String>,
37}
38
39/// Resolves a user-supplied input string against the address book.
40///
41/// Supports `@label` shortcuts (e.g. `@main-wallet`) and direct address
42/// matching. Returns the original input unchanged if no match is found.
43///
44/// # Errors
45///
46/// Returns a user-facing error string when `@label` is used but the label
47/// doesn't exist in the address book — this should be returned as a 400.
48pub fn resolve_address_book(
49    input: &str,
50    config: &crate::config::Config,
51) -> Result<ResolvedInput, String> {
52    match crate::cli::address_book::resolve_address_book_input(input, config) {
53        Ok(Some((address, chain))) => Ok(ResolvedInput {
54            value: address,
55            chain: Some(chain),
56        }),
57        Ok(None) => Ok(ResolvedInput {
58            value: input.to_string(),
59            chain: None,
60        }),
61        Err(e) => Err(e.to_string()),
62    }
63}
64
65/// Registers all API routes under the `/api` prefix.
66pub fn routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
67    Router::new()
68        .route("/address", axum::routing::post(address::handle))
69        .route("/tx", axum::routing::post(tx::handle))
70        .route("/insights", axum::routing::post(insights::handle))
71        .route("/crawl", axum::routing::post(crawl::handle))
72        .route("/discover", axum::routing::get(discover::handle))
73        .route("/token-health", axum::routing::post(token_health::handle))
74        .route("/market/summary", axum::routing::post(market::handle))
75        .route(
76            "/address-book/list",
77            axum::routing::get(address_book::handle_list),
78        )
79        .route(
80            "/address-book/add",
81            axum::routing::post(address_book::handle_add),
82        )
83        .route(
84            "/address-book/remove",
85            axum::routing::post(address_book::handle_remove),
86        )
87        .route("/contract", axum::routing::post(contract::handle))
88        .route("/export", axum::routing::post(export::handle))
89        .route(
90            "/compliance/risk",
91            axum::routing::post(compliance::handle_risk),
92        )
93        .route("/config/status", axum::routing::get(config_status::handle))
94        .route("/config", axum::routing::post(config_status::handle_save))
95        .route("/venues", axum::routing::get(venues::handle))
96        .route("/exchange/snapshot", axum::routing::post(exchange::handle))
97        .route(
98            "/exchange/trades",
99            axum::routing::post(exchange::handle_trades),
100        )
101        .route("/exchange/ohlc", axum::routing::post(exchange::handle_ohlc))
102        .with_state(state)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::chains::DefaultClientFactory;
109    use crate::cli::address_book::{AddressBook, WatchedAddress};
110    use crate::config::Config;
111
112    #[test]
113    fn test_routes_construction() {
114        let config = Config::default();
115        let http: std::sync::Arc<dyn crate::http::HttpClient> =
116            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
117        let factory = DefaultClientFactory {
118            chains_config: config.chains.clone(),
119            http,
120        };
121        let state = Arc::new(AppState { config, factory });
122        let _router = routes(state);
123        // If this doesn't panic, routes are properly constructed
124    }
125
126    #[test]
127    fn test_resolve_address_book_label_found() {
128        let tmp = tempfile::tempdir().unwrap();
129        let mut ab = AddressBook::default();
130        ab.add_address(WatchedAddress {
131            address: "0xABCDEF1234567890abcdef1234567890ABCDEF12".to_string(),
132            label: Some("hot-wallet".to_string()),
133            chain: "polygon".to_string(),
134            tags: vec![],
135            added_at: 0,
136        })
137        .unwrap();
138        ab.save(&tmp.path().to_path_buf()).unwrap();
139
140        let config = Config {
141            address_book: crate::config::AddressBookConfig {
142                data_dir: Some(tmp.path().to_path_buf()),
143            },
144            ..Default::default()
145        };
146        let result = resolve_address_book("@hot-wallet", &config);
147        assert!(result.is_ok());
148        let r = result.unwrap();
149        assert_eq!(r.value, "0xABCDEF1234567890abcdef1234567890ABCDEF12");
150        assert_eq!(r.chain, Some("polygon".to_string()));
151    }
152
153    #[test]
154    fn test_resolve_address_book_label_not_found() {
155        let tmp = tempfile::tempdir().unwrap();
156        let config = Config {
157            address_book: crate::config::AddressBookConfig {
158                data_dir: Some(tmp.path().to_path_buf()),
159            },
160            ..Default::default()
161        };
162        let result = resolve_address_book("@nonexistent", &config);
163        assert!(result.is_err());
164        assert!(result.unwrap_err().contains("@nonexistent"));
165    }
166
167    #[test]
168    fn test_resolve_address_book_raw_address_passthrough() {
169        let tmp = tempfile::tempdir().unwrap();
170        let config = Config {
171            address_book: crate::config::AddressBookConfig {
172                data_dir: Some(tmp.path().to_path_buf()),
173            },
174            ..Default::default()
175        };
176        let result = resolve_address_book("0xSomeRawAddress", &config);
177        assert!(result.is_ok());
178        let r = result.unwrap();
179        assert_eq!(r.value, "0xSomeRawAddress");
180        assert_eq!(r.chain, None);
181    }
182
183    #[test]
184    fn test_resolve_address_book_address_match_returns_chain() {
185        let tmp = tempfile::tempdir().unwrap();
186        let mut ab = AddressBook::default();
187        ab.add_address(WatchedAddress {
188            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
189            label: Some("main".to_string()),
190            chain: "arbitrum".to_string(),
191            tags: vec![],
192            added_at: 0,
193        })
194        .unwrap();
195        ab.save(&tmp.path().to_path_buf()).unwrap();
196
197        let config = Config {
198            address_book: crate::config::AddressBookConfig {
199                data_dir: Some(tmp.path().to_path_buf()),
200            },
201            ..Default::default()
202        };
203        // Pass raw address (no @ prefix) — should still match and return chain
204        let result = resolve_address_book("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config);
205        assert!(result.is_ok());
206        let r = result.unwrap();
207        assert_eq!(r.value, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
208        assert_eq!(r.chain, Some("arbitrum".to_string()));
209    }
210
211    #[test]
212    fn test_resolve_address_book_token_symbol_passthrough() {
213        let tmp = tempfile::tempdir().unwrap();
214        let config = Config {
215            address_book: crate::config::AddressBookConfig {
216                data_dir: Some(tmp.path().to_path_buf()),
217            },
218            ..Default::default()
219        };
220        // Token symbols like "USDC" should pass through unchanged
221        let result = resolve_address_book("USDC", &config);
222        assert!(result.is_ok());
223        let r = result.unwrap();
224        assert_eq!(r.value, "USDC");
225        assert_eq!(r.chain, None);
226    }
227}