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