1use 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
30async 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 ) -> 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
105pub 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 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 let service = start_faucet(app_state)
182 .await
183 .expect("Failed to start faucet");
184
185 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
187
188 let client = reqwest::Client::new();
189
190 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 assert!(faucet_response.coins_sent.is_some());
205
206 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}