Skip to main content

scope/web/api/
contract.rs

1//! # Contract Analysis API Handler
2//!
3//! POST /api/contract - Analyze a smart contract address.
4
5use crate::chains::ChainClientFactory;
6use crate::contract;
7use crate::web::AppState;
8use axum::Json;
9use axum::extract::State;
10use axum::http::StatusCode;
11use axum::response::IntoResponse;
12use serde::Deserialize;
13use std::sync::Arc;
14
15/// Request body for contract analysis.
16#[derive(Debug, Deserialize)]
17pub struct ContractRequest {
18    /// Contract address to analyze.
19    pub address: String,
20    /// Chain (default: ethereum).
21    #[serde(default = "default_chain")]
22    pub chain: String,
23}
24
25fn default_chain() -> String {
26    "ethereum".to_string()
27}
28
29/// Handle contract analysis request.
30///
31/// Supports address book shortcuts: pass `@label` as the address to
32/// resolve it from the address book.
33pub async fn handle(
34    State(state): State<Arc<AppState>>,
35    Json(req): Json<ContractRequest>,
36) -> impl IntoResponse {
37    // Resolve address book shortcuts (@label or direct address match)
38    let resolved = match super::resolve_address_book(&req.address, &state.config) {
39        Ok(r) => r,
40        Err(e) => {
41            return (
42                StatusCode::BAD_REQUEST,
43                Json(serde_json::json!({ "error": e })),
44            )
45                .into_response();
46        }
47    };
48    let address = resolved.value;
49    let chain = resolved.chain.unwrap_or(req.chain);
50
51    let client: Box<dyn crate::chains::ChainClient> =
52        match state.factory.create_chain_client(&chain) {
53            Ok(c) => c,
54            Err(e) => {
55                let err_msg = e.to_string();
56                return (
57                    StatusCode::BAD_REQUEST,
58                    Json(serde_json::json!({ "error": err_msg })),
59                )
60                    .into_response();
61            }
62        };
63
64    let http_client = reqwest::Client::new();
65
66    match contract::analyze_contract(&address, &chain, client.as_ref(), &http_client).await {
67        Ok(analysis) => (StatusCode::OK, Json(serde_json::json!(analysis))).into_response(),
68        Err(e) => {
69            let err_msg = e.to_string();
70            (
71                StatusCode::INTERNAL_SERVER_ERROR,
72                Json(serde_json::json!({ "error": err_msg })),
73            )
74                .into_response()
75        }
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn test_contract_deserialize_full() {
85        let json = serde_json::json!({
86            "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
87            "chain": "polygon"
88        });
89        let req: ContractRequest = serde_json::from_value(json).unwrap();
90        assert_eq!(req.address, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
91        assert_eq!(req.chain, "polygon");
92    }
93
94    #[test]
95    fn test_contract_deserialize_minimal() {
96        let json = serde_json::json!({
97            "address": "0x1234567890123456789012345678901234567890"
98        });
99        let req: ContractRequest = serde_json::from_value(json).unwrap();
100        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
101        assert_eq!(req.chain, "ethereum");
102    }
103
104    #[test]
105    fn test_contract_default_chain() {
106        assert_eq!(default_chain(), "ethereum");
107    }
108
109    #[test]
110    fn test_contract_request_debug() {
111        let req = ContractRequest {
112            address: "0xabc".to_string(),
113            chain: "ethereum".to_string(),
114        };
115        let debug = format!("{:?}", req);
116        assert!(debug.contains("ContractRequest"));
117    }
118
119    #[tokio::test]
120    async fn test_handle_contract_direct() {
121        use crate::chains::DefaultClientFactory;
122        use crate::config::Config;
123        use crate::web::AppState;
124        use axum::extract::State;
125        use axum::response::IntoResponse;
126
127        let config = Config::default();
128        let http: std::sync::Arc<dyn crate::http::HttpClient> =
129            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
130        let factory = DefaultClientFactory {
131            chains_config: config.chains.clone(),
132            http,
133        };
134        let state = std::sync::Arc::new(AppState { config, factory });
135        let req = ContractRequest {
136            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
137            chain: "ethereum".to_string(),
138        };
139        let response = handle(State(state), axum::Json(req)).await.into_response();
140        let status = response.status();
141        assert!(
142            status.is_success()
143                || status == axum::http::StatusCode::BAD_REQUEST
144                || status.is_server_error()
145        );
146    }
147
148    #[tokio::test]
149    async fn test_handle_contract_solana_chain() {
150        use crate::chains::DefaultClientFactory;
151        use crate::config::Config;
152        use crate::web::AppState;
153        use axum::extract::State;
154        use axum::response::IntoResponse;
155
156        let config = Config::default();
157        let http: std::sync::Arc<dyn crate::http::HttpClient> =
158            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
159        let factory = DefaultClientFactory {
160            chains_config: config.chains.clone(),
161            http,
162        };
163        let state = std::sync::Arc::new(AppState { config, factory });
164        let req = ContractRequest {
165            address: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(),
166            chain: "solana".to_string(),
167        };
168        let response = handle(State(state), axum::Json(req)).await.into_response();
169        let status = response.status();
170        assert!(
171            status.is_success()
172                || status == axum::http::StatusCode::BAD_REQUEST
173                || status.is_server_error()
174        );
175    }
176
177    #[tokio::test]
178    async fn test_handle_contract_label_not_found() {
179        use crate::chains::DefaultClientFactory;
180        use crate::config::Config;
181        use crate::web::AppState;
182        use axum::extract::State;
183        use axum::http::StatusCode;
184        use axum::response::IntoResponse;
185
186        let tmp = tempfile::tempdir().unwrap();
187        let config = Config {
188            address_book: crate::config::AddressBookConfig {
189                data_dir: Some(tmp.path().to_path_buf()),
190            },
191            ..Default::default()
192        };
193        let http: std::sync::Arc<dyn crate::http::HttpClient> =
194            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
195        let factory = DefaultClientFactory {
196            chains_config: config.chains.clone(),
197            http,
198        };
199        let state = std::sync::Arc::new(AppState { config, factory });
200        let req = ContractRequest {
201            address: "@missing-label".to_string(),
202            chain: "ethereum".to_string(),
203        };
204        let response = handle(State(state), axum::Json(req)).await.into_response();
205        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
206        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
207            .await
208            .unwrap();
209        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
210        assert!(json["error"].as_str().unwrap().contains("@missing-label"));
211    }
212}