1use crate::AppState;
4use axum::Json;
5use axum::extract::State;
6use axum::response::IntoResponse;
7use scope_cli::cli::address::{self, AddressArgs};
8use serde::Deserialize;
9use std::sync::Arc;
10
11use super::{error_response, status_for};
12
13#[derive(Debug, Deserialize)]
15pub struct AddressRequest {
16 pub address: String,
18 #[serde(default = "super::default_chain")]
20 pub chain: String,
21 #[serde(default)]
23 pub include_txs: bool,
24 #[serde(default)]
26 pub include_tokens: bool,
27 #[serde(default = "default_limit")]
29 pub limit: u32,
30 #[serde(default)]
32 pub dossier: bool,
33}
34
35fn default_limit() -> u32 {
36 100
37}
38
39pub async fn handle(
45 State(state): State<Arc<AppState>>,
46 Json(req): Json<AddressRequest>,
47) -> impl IntoResponse {
48 let resolved = match super::resolve_address_book(&req.address, &state.config) {
50 Ok(r) => r,
51 Err(e) => return error_response(status_for(&e), e.to_string()),
52 };
53 let address = resolved.value;
54 let chain = resolved.chain.unwrap_or(req.chain);
55
56 let args = AddressArgs {
57 address,
58 chain,
59 format: None,
60 include_txs: req.include_txs,
61 include_tokens: req.include_tokens,
62 limit: req.limit,
63 report: None,
64 dossier: req.dossier,
65 };
66
67 let client: Box<dyn scope::chains::ChainClient> =
68 match state.factory.create_chain_client(&args.chain) {
69 Ok(c) => c,
70 Err(e) => return error_response(status_for(&e), e.to_string()),
71 };
72
73 match address::analyze_address(
74 &args,
75 client.as_ref(),
76 &scope_cli::cli::output::Output::stdio().without_prompts(),
77 )
78 .await
79 {
80 Ok(report) => Json(serde_json::json!(report)).into_response(),
81 Err(e) => error_response(status_for(&e), e.to_string()),
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn test_deserialize_full() {
91 let json = serde_json::json!({
92 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
93 "chain": "polygon",
94 "include_txs": true,
95 "include_tokens": true,
96 "limit": 50,
97 "dossier": true
98 });
99 let req: AddressRequest = serde_json::from_value(json).unwrap();
100 assert_eq!(req.address, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
101 assert_eq!(req.chain, "polygon");
102 assert!(req.include_txs);
103 assert!(req.include_tokens);
104 assert_eq!(req.limit, 50);
105 assert!(req.dossier);
106 }
107
108 #[test]
109 fn test_deserialize_minimal() {
110 let json = serde_json::json!({
111 "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"
112 });
113 let req: AddressRequest = serde_json::from_value(json).unwrap();
114 assert_eq!(req.address, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
115 assert_eq!(req.chain, "ethereum");
116 assert!(!req.include_txs);
117 assert!(!req.include_tokens);
118 assert_eq!(req.limit, 100);
119 assert!(!req.dossier);
120 }
121
122 #[test]
123 fn test_defaults() {
124 assert_eq!(super::super::default_chain(), "ethereum");
125 assert_eq!(default_limit(), 100);
126 }
127
128 #[test]
129 fn test_deserialize_with_options() {
130 let json = serde_json::json!({
131 "address": "0x1234567890123456789012345678901234567890",
132 "include_txs": true,
133 "dossier": true
134 });
135 let req: AddressRequest = serde_json::from_value(json).unwrap();
136 assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
137 assert_eq!(req.chain, "ethereum");
138 assert!(req.include_txs);
139 assert!(!req.include_tokens);
140 assert_eq!(req.limit, 100);
141 assert!(req.dossier);
142 }
143
144 #[tokio::test]
145 async fn test_handle_address_direct() {
146 use crate::AppState;
147 use axum::extract::State;
148 use axum::response::IntoResponse;
149 use scope::chains::DefaultClientFactory;
150 use scope::config::Config;
151
152 let config = Config::default();
153 let http: std::sync::Arc<dyn scope::http::HttpClient> =
154 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
155 let factory = DefaultClientFactory {
156 chains_config: config.chains.clone(),
157 http,
158 };
159 let state = std::sync::Arc::new(AppState::new(config, factory));
160 let req = AddressRequest {
161 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
162 chain: "ethereum".to_string(),
163 include_txs: false,
164 include_tokens: true,
165 limit: 10,
166 dossier: false,
167 };
168 let response = handle(State(state), axum::Json(req)).await.into_response();
169 let status = response.status();
171 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
172 }
173
174 #[tokio::test]
175 async fn test_handle_address_unsupported_chain_bad_request() {
176 use crate::AppState;
177 use axum::extract::State;
178 use axum::http::StatusCode;
179 use axum::response::IntoResponse;
180 use scope::chains::DefaultClientFactory;
181 use scope::config::Config;
182
183 let tmp = tempfile::tempdir().unwrap();
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 http: std::sync::Arc<dyn scope::http::HttpClient> =
192 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
193 let factory = DefaultClientFactory {
194 chains_config: config.chains.clone(),
195 http,
196 };
197 let state = std::sync::Arc::new(AppState::new(config, factory));
198 let req = AddressRequest {
199 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
200 chain: "bitcoin".to_string(),
201 include_txs: false,
202 include_tokens: true,
203 limit: 10,
204 dossier: false,
205 };
206 let response = handle(State(state), axum::Json(req)).await.into_response();
207 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
208 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
209 .await
210 .unwrap();
211 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
212 assert!(
213 json["error"]
214 .as_str()
215 .unwrap()
216 .contains("Unsupported chain")
217 );
218 }
219
220 #[tokio::test]
221 async fn test_handle_address_book_label_not_found() {
222 use crate::AppState;
223 use axum::extract::State;
224 use axum::http::StatusCode;
225 use axum::response::IntoResponse;
226 use scope::chains::DefaultClientFactory;
227 use scope::config::Config;
228
229 let tmp = tempfile::tempdir().unwrap();
230 let config = Config {
231 address_book: scope::config::AddressBookConfig {
232 data_dir: Some(tmp.path().to_path_buf()),
233 },
234 ..Default::default()
235 };
236 let http: std::sync::Arc<dyn scope::http::HttpClient> =
237 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
238 let factory = DefaultClientFactory {
239 chains_config: config.chains.clone(),
240 http,
241 };
242 let state = std::sync::Arc::new(AppState::new(config, factory));
243 let req = AddressRequest {
244 address: "@nonexistent-label".to_string(),
245 chain: "ethereum".to_string(),
246 include_txs: false,
247 include_tokens: false,
248 limit: 10,
249 dossier: false,
250 };
251 let response = handle(State(state), axum::Json(req)).await.into_response();
252 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
253 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
254 .await
255 .unwrap();
256 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
257 assert!(
258 json["error"]
259 .as_str()
260 .unwrap()
261 .contains("@nonexistent-label")
262 );
263 }
264}