sui_faucet/
server.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::types::*;
5use crate::{AppState, FaucetConfig, FaucetError, FaucetRequest};
6use anyhow::Context;
7use axum::{
8    BoxError, Extension, Json, Router,
9    error_handling::HandleErrorLayer,
10    http::StatusCode,
11    response::IntoResponse,
12    routing::{get, post},
13};
14use http::Method;
15use std::{
16    borrow::Cow,
17    net::{IpAddr, SocketAddr},
18    path::PathBuf,
19    sync::Arc,
20    time::Duration,
21};
22use sui_config::SUI_CLIENT_CONFIG;
23use sui_futures::service::Service;
24use sui_sdk::wallet_context::WalletContext;
25use tokio::sync::oneshot;
26use tower::ServiceBuilder;
27use tower_http::cors::{Any, CorsLayer};
28use tracing::info;
29
30/// basic handler that responds with a static string
31async fn health() -> &'static str {
32    "OK"
33}
34
35async fn request_local_gas(
36    Extension(state): Extension<Arc<AppState>>,
37    Json(payload): Json<FaucetRequest>,
38    // ) -> &'static str {
39) -> impl IntoResponse {
40    let FaucetRequest::FixedAmountRequest(request) = payload;
41    info!("Local request for address: {}", request.recipient);
42    let request = state
43        .faucet
44        .local_request_execute_tx(request.recipient)
45        .await;
46
47    if let Err(e) = request {
48        return (
49            StatusCode::INTERNAL_SERVER_ERROR,
50            Json(FaucetResponse {
51                status: RequestStatus::Failure(e),
52                coins_sent: None,
53            }),
54        );
55    }
56
57    let Ok(coins) = request else {
58        return (
59            StatusCode::INTERNAL_SERVER_ERROR,
60            Json(FaucetResponse {
61                status: RequestStatus::Failure(FaucetError::internal(format!(
62                    "Failed to execute transaction: {}",
63                    request.unwrap_err()
64                ))),
65                coins_sent: None,
66            }),
67        );
68    };
69
70    (
71        StatusCode::OK,
72        Json(FaucetResponse {
73            status: RequestStatus::Success,
74            coins_sent: Some(coins),
75        }),
76    )
77}
78
79pub fn create_wallet_context(
80    timeout_secs: u64,
81    config_dir: PathBuf,
82) -> Result<WalletContext, anyhow::Error> {
83    let wallet_conf = config_dir.join(SUI_CLIENT_CONFIG);
84    info!("Initialize wallet from config path: {:?}", wallet_conf);
85    WalletContext::new(&wallet_conf).map(|ctx| {
86        ctx.with_request_timeout(Duration::from_secs(timeout_secs))
87            .with_max_concurrent_requests(1000)
88    })
89}
90
91async fn handle_error(error: BoxError) -> impl IntoResponse {
92    if error.is::<tower::load_shed::error::Overloaded>() {
93        return (
94            StatusCode::SERVICE_UNAVAILABLE,
95            Cow::from("service is overloaded, please try again later"),
96        );
97    }
98
99    (
100        StatusCode::INTERNAL_SERVER_ERROR,
101        Cow::from(format!("Unhandled internal error: {}", error)),
102    )
103}
104
105/// Start a faucet that is run locally. This should only be used for starting a local network, and
106/// not for devnet/testnet deployments!
107pub async fn start_faucet(app_state: Arc<AppState>) -> anyhow::Result<Service> {
108    let cors = CorsLayer::new()
109        .allow_methods(vec![Method::GET, Method::POST])
110        .allow_headers(Any)
111        .allow_origin(Any);
112    let FaucetConfig { port, host_ip, .. } = app_state.config;
113
114    let app = Router::new()
115        .route("/", get(health))
116        .route("/v2/gas", post(request_local_gas))
117        .route("/v1/gas", post(request_local_gas))
118        .route("/gas", post(request_local_gas))
119        .layer(
120            ServiceBuilder::new()
121                .layer(HandleErrorLayer::new(handle_error))
122                .load_shed()
123                .layer(Extension(app_state.clone()))
124                .layer(cors)
125                .into_inner(),
126        );
127
128    let addr = SocketAddr::new(IpAddr::V4(host_ip), port);
129    info!("Starting local faucet service on {addr}");
130    let listener = tokio::net::TcpListener::bind(addr)
131        .await
132        .context("Failed to bind faucet to listen address")?;
133
134    let (stx, srx) = oneshot::channel::<()>();
135    Ok(Service::new()
136        .with_shutdown_signal(async move {
137            let _ = stx.send(());
138        })
139        .spawn(async move {
140            axum::serve(
141                listener,
142                app.into_make_service_with_connect_info::<SocketAddr>(),
143            )
144            .with_graceful_shutdown(async move {
145                let _ = srx.await;
146                info!("Shutdown received, shutting down faucet service");
147            })
148            .await
149            .context("Failed to start faucet service")
150        }))
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::LocalFaucet;
157    use serde_json::json;
158    use sui_sdk::types::base_types::SuiAddress;
159    use test_cluster::TestClusterBuilder;
160
161    #[tokio::test]
162    async fn test_v2_gas_endpoint() {
163        // Setup test cluster and faucet
164        let cluster = TestClusterBuilder::new().build().await;
165        let port = 9090;
166        let config = FaucetConfig {
167            host_ip: "127.0.0.1".parse().unwrap(),
168            port,
169            ..Default::default()
170        };
171        let local_faucet = LocalFaucet::new(cluster.wallet, config.clone())
172            .await
173            .unwrap();
174
175        let app_state = Arc::new(AppState {
176            faucet: local_faucet,
177            config,
178        });
179
180        // Start the faucet as a background Service
181        let service = start_faucet(app_state)
182            .await
183            .expect("Failed to start faucet");
184
185        // Give the server a moment to start
186        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
187
188        let client = reqwest::Client::new();
189
190        // Test successful request
191        let recipient = SuiAddress::random_for_testing_only();
192        let req = FaucetRequest::new_fixed_amount_request(recipient);
193        let response = client
194            .post(format!("http://127.0.0.1:{port}/v2/gas",))
195            .json(&req)
196            .send()
197            .await
198            .unwrap();
199
200        assert_eq!(response.status(), StatusCode::OK);
201        let faucet_response = response.json::<FaucetResponse>().await.unwrap();
202
203        // Verify the transaction was successful
204        assert!(faucet_response.coins_sent.is_some());
205
206        // Test invalid request
207        let response = client
208            .post(format!("http://127.0.0.1:{port}/v2/gas",))
209            .json(&json!({
210                "recipient": recipient.to_string(),
211            }))
212            .send()
213            .await
214            .unwrap();
215
216        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
217        service
218            .shutdown()
219            .await
220            .expect("Failed to shut down faucet");
221    }
222}