1pub 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::AppState;
22use axum::Router;
23use std::sync::Arc;
24
25pub(crate) fn status_for(err: &scope::error::ScopeError) -> axum::http::StatusCode {
29 use axum::http::StatusCode;
30 use scope::error::ScopeError;
31 match err {
32 ScopeError::NotFound(_) => StatusCode::NOT_FOUND,
33 ScopeError::InvalidInput(_)
34 | ScopeError::InvalidAddress(_)
35 | ScopeError::InvalidHash(_) => StatusCode::BAD_REQUEST,
36 _ => StatusCode::INTERNAL_SERVER_ERROR,
37 }
38}
39
40pub(crate) fn error_response(
42 status: axum::http::StatusCode,
43 message: impl Into<String>,
44) -> axum::response::Response {
45 use axum::response::IntoResponse;
46 let body = serde_json::json!({ "error": message.into() });
47 (status, axum::Json(body)).into_response()
48}
49
50pub(crate) fn default_chain() -> String {
52 scope::chains::DEFAULT_CHAIN.to_string()
53}
54
55pub(crate) fn default_venue() -> String {
57 scope::market::DEFAULT_VENUE.to_string()
58}
59
60pub(crate) fn default_pair() -> String {
62 scope::market::DEFAULT_PAIR.to_string()
63}
64
65#[derive(Debug)]
72pub struct ResolvedInput {
73 pub value: String,
75 pub chain: Option<String>,
77}
78
79pub fn resolve_address_book(
90 input: &str,
91 config: &scope::config::Config,
92) -> scope::error::Result<ResolvedInput> {
93 match scope_cli::cli::address_book::resolve_address_book_input(input, config) {
94 Ok(Some((address, chain))) => Ok(ResolvedInput {
95 value: address,
96 chain: Some(chain),
97 }),
98 Ok(None) => Ok(ResolvedInput {
99 value: input.to_string(),
100 chain: None,
101 }),
102 Err(scope::error::ScopeError::NotFound(msg)) => {
104 Err(scope::error::ScopeError::InvalidInput(msg))
105 }
106 Err(e) => Err(scope::error::ScopeError::InvalidInput(e.to_string())),
107 }
108}
109
110pub fn routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
112 Router::new()
113 .route("/address", axum::routing::post(address::handle))
114 .route("/tx", axum::routing::post(tx::handle))
115 .route("/insights", axum::routing::post(insights::handle))
116 .route("/crawl", axum::routing::post(crawl::handle))
117 .route("/discover", axum::routing::get(discover::handle))
118 .route("/token-health", axum::routing::post(token_health::handle))
119 .route("/market/summary", axum::routing::post(market::handle))
120 .route(
121 "/address-book/list",
122 axum::routing::get(address_book::handle_list),
123 )
124 .route(
125 "/address-book/add",
126 axum::routing::post(address_book::handle_add),
127 )
128 .route(
129 "/address-book/remove",
130 axum::routing::post(address_book::handle_remove),
131 )
132 .route("/contract", axum::routing::post(contract::handle))
133 .route("/export", axum::routing::post(export::handle))
134 .route(
135 "/compliance/risk",
136 axum::routing::post(compliance::handle_risk),
137 )
138 .route("/config/status", axum::routing::get(config_status::handle))
139 .route("/config", axum::routing::post(config_status::handle_save))
140 .route("/venues", axum::routing::get(venues::handle))
141 .route("/exchange/snapshot", axum::routing::post(exchange::handle))
142 .route(
143 "/exchange/trades",
144 axum::routing::post(exchange::handle_trades),
145 )
146 .route("/exchange/ohlc", axum::routing::post(exchange::handle_ohlc))
147 .with_state(state)
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use scope::chains::DefaultClientFactory;
154 use scope::config::Config;
155 use scope_cli::cli::address_book::{AddressBook, WatchedAddress};
156
157 #[test]
158 fn test_routes_construction() {
159 let config = Config::default();
160 let http: std::sync::Arc<dyn scope::http::HttpClient> =
161 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
162 let factory = DefaultClientFactory {
163 chains_config: config.chains.clone(),
164 http,
165 };
166 let state = Arc::new(AppState::new(config, factory));
167 let _router = routes(state);
168 }
170
171 #[test]
172 fn test_resolve_address_book_label_found() {
173 let tmp = tempfile::tempdir().unwrap();
174 let mut ab = AddressBook::default();
175 ab.add_address(WatchedAddress {
176 address: "0xABCDEF1234567890abcdef1234567890ABCDEF12".to_string(),
177 label: Some("hot-wallet".to_string()),
178 chain: "polygon".to_string(),
179 tags: vec![],
180 added_at: 0,
181 })
182 .unwrap();
183 ab.save(&tmp.path().to_path_buf()).unwrap();
184
185 let config = Config {
186 address_book: scope::config::AddressBookConfig {
187 data_dir: Some(tmp.path().to_path_buf()),
188 },
189 ..Default::default()
190 };
191 let result = resolve_address_book("@hot-wallet", &config);
192 assert!(result.is_ok());
193 let r = result.unwrap();
194 assert_eq!(r.value, "0xABCDEF1234567890abcdef1234567890ABCDEF12");
195 assert_eq!(r.chain, Some("polygon".to_string()));
196 }
197
198 #[test]
199 fn test_resolve_address_book_label_not_found() {
200 let tmp = tempfile::tempdir().unwrap();
201 let config = Config {
202 address_book: scope::config::AddressBookConfig {
203 data_dir: Some(tmp.path().to_path_buf()),
204 },
205 ..Default::default()
206 };
207 let result = resolve_address_book("@nonexistent", &config);
208 assert!(result.is_err());
209 let err = result.unwrap_err();
210 assert!(matches!(err, scope::error::ScopeError::InvalidInput(_)));
211 assert!(err.to_string().contains("@nonexistent"));
212 }
213
214 #[test]
215 fn test_resolve_address_book_raw_address_passthrough() {
216 let tmp = tempfile::tempdir().unwrap();
217 let config = Config {
218 address_book: scope::config::AddressBookConfig {
219 data_dir: Some(tmp.path().to_path_buf()),
220 },
221 ..Default::default()
222 };
223 let result = resolve_address_book("0xSomeRawAddress", &config);
224 assert!(result.is_ok());
225 let r = result.unwrap();
226 assert_eq!(r.value, "0xSomeRawAddress");
227 assert_eq!(r.chain, None);
228 }
229
230 #[test]
231 fn test_resolve_address_book_address_match_returns_chain() {
232 let tmp = tempfile::tempdir().unwrap();
233 let mut ab = AddressBook::default();
234 ab.add_address(WatchedAddress {
235 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
236 label: Some("main".to_string()),
237 chain: "arbitrum".to_string(),
238 tags: vec![],
239 added_at: 0,
240 })
241 .unwrap();
242 ab.save(&tmp.path().to_path_buf()).unwrap();
243
244 let config = Config {
245 address_book: scope::config::AddressBookConfig {
246 data_dir: Some(tmp.path().to_path_buf()),
247 },
248 ..Default::default()
249 };
250 let result = resolve_address_book("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config);
252 assert!(result.is_ok());
253 let r = result.unwrap();
254 assert_eq!(r.value, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
255 assert_eq!(r.chain, Some("arbitrum".to_string()));
256 }
257
258 #[test]
259 fn test_resolve_address_book_token_symbol_passthrough() {
260 let tmp = tempfile::tempdir().unwrap();
261 let config = Config {
262 address_book: scope::config::AddressBookConfig {
263 data_dir: Some(tmp.path().to_path_buf()),
264 },
265 ..Default::default()
266 };
267 let result = resolve_address_book("USDC", &config);
269 assert!(result.is_ok());
270 let r = result.unwrap();
271 assert_eq!(r.value, "USDC");
272 assert_eq!(r.chain, None);
273 }
274}
275
276#[cfg(test)]
277mod status_tests {
278 use super::*;
279 use axum::http::StatusCode;
280 use scope::error::ScopeError;
281
282 #[test]
286 fn core_error_kinds_map_to_one_status_each() {
287 let no_pairs = ScopeError::NotFound("No DEX pairs found for USDC on solana".into());
288 assert_eq!(status_for(&no_pairs), StatusCode::NOT_FOUND);
289 assert_eq!(
290 status_for(&ScopeError::InvalidAddress("0x1".into())),
291 StatusCode::BAD_REQUEST
292 );
293 assert_eq!(
294 status_for(&ScopeError::Network("timeout".into())),
295 StatusCode::INTERNAL_SERVER_ERROR
296 );
297 }
298}