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 factory = DefaultClientFactory {
129            chains_config: config.chains.clone(),
130        };
131        let state = std::sync::Arc::new(AppState { config, factory });
132        let req = ContractRequest {
133            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
134            chain: "ethereum".to_string(),
135        };
136        let response = handle(State(state), axum::Json(req)).await.into_response();
137        let status = response.status();
138        assert!(
139            status.is_success()
140                || status == axum::http::StatusCode::BAD_REQUEST
141                || status.is_server_error()
142        );
143    }
144
145    #[tokio::test]
146    async fn test_handle_contract_solana_chain() {
147        use crate::chains::DefaultClientFactory;
148        use crate::config::Config;
149        use crate::web::AppState;
150        use axum::extract::State;
151        use axum::response::IntoResponse;
152
153        let config = Config::default();
154        let factory = DefaultClientFactory {
155            chains_config: config.chains.clone(),
156        };
157        let state = std::sync::Arc::new(AppState { config, factory });
158        let req = ContractRequest {
159            address: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(),
160            chain: "solana".to_string(),
161        };
162        let response = handle(State(state), axum::Json(req)).await.into_response();
163        let status = response.status();
164        assert!(
165            status.is_success()
166                || status == axum::http::StatusCode::BAD_REQUEST
167                || status.is_server_error()
168        );
169    }
170
171    #[tokio::test]
172    async fn test_handle_contract_label_not_found() {
173        use crate::chains::DefaultClientFactory;
174        use crate::config::Config;
175        use crate::web::AppState;
176        use axum::extract::State;
177        use axum::http::StatusCode;
178        use axum::response::IntoResponse;
179
180        let tmp = tempfile::tempdir().unwrap();
181        let config = Config {
182            address_book: crate::config::AddressBookConfig {
183                data_dir: Some(tmp.path().to_path_buf()),
184            },
185            ..Default::default()
186        };
187        let factory = DefaultClientFactory {
188            chains_config: config.chains.clone(),
189        };
190        let state = std::sync::Arc::new(AppState { config, factory });
191        let req = ContractRequest {
192            address: "@missing-label".to_string(),
193            chain: "ethereum".to_string(),
194        };
195        let response = handle(State(state), axum::Json(req)).await.into_response();
196        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
197        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
198            .await
199            .unwrap();
200        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
201        assert!(json["error"].as_str().unwrap().contains("@missing-label"));
202    }
203}