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