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 factory = DefaultClientFactory {
116            chains_config: config.chains.clone(),
117        };
118        let state = Arc::new(AppState { config, factory });
119        let _router = routes(state);
120        // If this doesn't panic, routes are properly constructed
121    }
122
123    #[test]
124    fn test_resolve_address_book_label_found() {
125        let tmp = tempfile::tempdir().unwrap();
126        let mut ab = AddressBook::default();
127        ab.add_address(WatchedAddress {
128            address: "0xABCDEF1234567890abcdef1234567890ABCDEF12".to_string(),
129            label: Some("hot-wallet".to_string()),
130            chain: "polygon".to_string(),
131            tags: vec![],
132            added_at: 0,
133        })
134        .unwrap();
135        ab.save(&tmp.path().to_path_buf()).unwrap();
136
137        let config = Config {
138            address_book: crate::config::AddressBookConfig {
139                data_dir: Some(tmp.path().to_path_buf()),
140            },
141            ..Default::default()
142        };
143        let result = resolve_address_book("@hot-wallet", &config);
144        assert!(result.is_ok());
145        let r = result.unwrap();
146        assert_eq!(r.value, "0xABCDEF1234567890abcdef1234567890ABCDEF12");
147        assert_eq!(r.chain, Some("polygon".to_string()));
148    }
149
150    #[test]
151    fn test_resolve_address_book_label_not_found() {
152        let tmp = tempfile::tempdir().unwrap();
153        let config = Config {
154            address_book: crate::config::AddressBookConfig {
155                data_dir: Some(tmp.path().to_path_buf()),
156            },
157            ..Default::default()
158        };
159        let result = resolve_address_book("@nonexistent", &config);
160        assert!(result.is_err());
161        assert!(result.unwrap_err().contains("@nonexistent"));
162    }
163
164    #[test]
165    fn test_resolve_address_book_raw_address_passthrough() {
166        let tmp = tempfile::tempdir().unwrap();
167        let config = Config {
168            address_book: crate::config::AddressBookConfig {
169                data_dir: Some(tmp.path().to_path_buf()),
170            },
171            ..Default::default()
172        };
173        let result = resolve_address_book("0xSomeRawAddress", &config);
174        assert!(result.is_ok());
175        let r = result.unwrap();
176        assert_eq!(r.value, "0xSomeRawAddress");
177        assert_eq!(r.chain, None);
178    }
179
180    #[test]
181    fn test_resolve_address_book_address_match_returns_chain() {
182        let tmp = tempfile::tempdir().unwrap();
183        let mut ab = AddressBook::default();
184        ab.add_address(WatchedAddress {
185            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
186            label: Some("main".to_string()),
187            chain: "arbitrum".to_string(),
188            tags: vec![],
189            added_at: 0,
190        })
191        .unwrap();
192        ab.save(&tmp.path().to_path_buf()).unwrap();
193
194        let config = Config {
195            address_book: crate::config::AddressBookConfig {
196                data_dir: Some(tmp.path().to_path_buf()),
197            },
198            ..Default::default()
199        };
200        // Pass raw address (no @ prefix) — should still match and return chain
201        let result = resolve_address_book("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config);
202        assert!(result.is_ok());
203        let r = result.unwrap();
204        assert_eq!(r.value, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
205        assert_eq!(r.chain, Some("arbitrum".to_string()));
206    }
207
208    #[test]
209    fn test_resolve_address_book_token_symbol_passthrough() {
210        let tmp = tempfile::tempdir().unwrap();
211        let config = Config {
212            address_book: crate::config::AddressBookConfig {
213                data_dir: Some(tmp.path().to_path_buf()),
214            },
215            ..Default::default()
216        };
217        // Token symbols like "USDC" should pass through unchanged
218        let result = resolve_address_book("USDC", &config);
219        assert!(result.is_ok());
220        let r = result.unwrap();
221        assert_eq!(r.value, "USDC");
222        assert_eq!(r.chain, None);
223    }
224}