diff --git a/Cargo.lock b/Cargo.lock index f37c5b0baa4c5..cf74f3c9ea4a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13616,15 +13616,19 @@ dependencies = [ "axum 0.7.5", "bin-version", "clap", + "dashmap", "eyre", "futures", "http 1.1.0", "mysten-metrics", "mysten-network", + "once_cell", "parking_lot 0.12.1", "prometheus", + "reqwest 0.12.5", "scopeguard", "serde", + "serde_json", "shared-crypto", "sui-config", "sui-json-rpc-types", @@ -13645,6 +13649,7 @@ dependencies = [ "ttl_cache", "typed-store", "uuid 1.2.2", + "wiremock", ] [[package]] diff --git a/crates/sui-faucet/Cargo.toml b/crates/sui-faucet/Cargo.toml index 861ac0649731a..12605c04ea05c 100644 --- a/crates/sui-faucet/Cargo.toml +++ b/crates/sui-faucet/Cargo.toml @@ -12,6 +12,7 @@ async-trait.workspace = true axum.workspace = true bin-version.workspace = true clap.workspace = true +dashmap.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["full"] } tracing.workspace = true @@ -29,6 +30,8 @@ eyre.workspace = true tempfile.workspace = true parking_lot.workspace = true tonic.workspace = true +reqwest.workspace = true +once_cell.workspace = true tower_governor = "0.4.3" sui-json-rpc-types.workspace = true @@ -45,6 +48,8 @@ mysten-network.workspace = true [dev-dependencies] test-cluster.workspace = true +wiremock.workspace = true +serde_json.workspace = true [[bin]] name = "sui-faucet" diff --git a/crates/sui-faucet/src/errors.rs b/crates/sui-faucet/src/errors.rs index 1538a5b7be956..81f9696d4dde6 100644 --- a/crates/sui-faucet/src/errors.rs +++ b/crates/sui-faucet/src/errors.rs @@ -5,6 +5,12 @@ use thiserror::Error; #[derive(Error, Debug, PartialEq, Eq)] pub enum FaucetError { + #[error("Missing X-Turnstile-Token header. For testnet tokens, please use the Web UI: https://faucet.sui.io")] + MissingTurnstileTokenHeader, + + #[error("Request limit exceeded. {0}")] + TooManyRequests(String), + #[error("Faucet cannot read objects from fullnode: {0}")] FullnodeReadingError(String), @@ -42,6 +48,9 @@ pub enum FaucetError { #[error("Internal error: {0}")] Internal(String), + + #[error("Invalid user agent: {0}")] + InvalidUserAgent(String), } impl FaucetError { diff --git a/crates/sui-faucet/src/faucet/mod.rs b/crates/sui-faucet/src/faucet/mod.rs index 1483123803318..a3684d62fdfa9 100644 --- a/crates/sui-faucet/src/faucet/mod.rs +++ b/crates/sui-faucet/src/faucet/mod.rs @@ -125,6 +125,32 @@ pub struct FaucetConfig { #[clap(long, action = clap::ArgAction::Set, default_value_t = false)] pub batch_enabled: bool, + + /// Testnet faucet requires authentication via the Web UI at + /// This flag is used to indicate that authentication mode is enabled. + #[clap(long)] + pub authenticated: bool, + + /// Maximum number of requests per IP address. This is used for the authenticated mode. + #[clap(long, default_value_t = 3)] + pub max_requests_per_ip: u64, + + /// This is the amount of time to wait before adding one more quota to the rate limiter. Basically, + /// it ensures that we're not allowing too many requests all at once. This is very specific to + /// governor and tower-governor crates. This is used primarily for authenticated mode. A small + /// value will allow more requests to be processed in a short period of time. + #[clap(long, default_value_t = 10)] + pub replenish_quota_interval_ms: u64, + + /// The amount of seconds to wait before resetting the request count for the IP addresses recorded + /// by the rate limit layer. Default is 12 hours. This is used for authenticated mode. + #[clap(long, default_value_t = 3600*12)] + pub reset_time_interval_secs: u64, + + /// Interval time to run the task to clear the banned IP addresses by the rate limiter. This is + /// used for authenticated mode. + #[clap(long, default_value_t = 60)] + pub rate_limiter_cleanup_interval_secs: u64, } impl Default for FaucetConfig { @@ -143,6 +169,11 @@ impl Default for FaucetConfig { batch_request_size: 500, ttl_expiration: 300, batch_enabled: false, + authenticated: false, + max_requests_per_ip: 3, + replenish_quota_interval_ms: 10, + reset_time_interval_secs: 3600 * 12, + rate_limiter_cleanup_interval_secs: 60, } } } diff --git a/crates/sui-faucet/src/metrics.rs b/crates/sui-faucet/src/metrics.rs index e58a91ae80054..69ed2a40ec342 100644 --- a/crates/sui-faucet/src/metrics.rs +++ b/crates/sui-faucet/src/metrics.rs @@ -213,5 +213,8 @@ pub fn normalize_path(path: &str) -> &str { /// Determines whether the given path should be tracked for metrics collection. /// Only specified paths relevant to monitoring are included. pub fn is_path_tracked(path: &str) -> bool { - matches!(path, "/v1/gas" | "/gas" | "/v1/status") + matches!( + path, + "/v1/gas" | "/gas" | "/v1/status" | "/v1/faucet_web_gas" + ) } diff --git a/crates/sui-faucet/src/server.rs b/crates/sui-faucet/src/server.rs index dc1585e87d066..895b86aa330b3 100644 --- a/crates/sui-faucet/src/server.rs +++ b/crates/sui-faucet/src/server.rs @@ -3,14 +3,13 @@ use crate::{ AppState, BatchFaucetResponse, BatchStatusFaucetResponse, FaucetConfig, FaucetError, - FaucetRequest, FaucetResponse, RequestMetricsLayer, + FaucetRequest, FaucetResponse, FixedAmountRequest, RequestMetricsLayer, }; - use axum::{ error_handling::HandleErrorLayer, - extract::Path, - http::StatusCode, - response::IntoResponse, + extract::{ConnectInfo, Host, Path}, + http::{header::HeaderMap, StatusCode}, + response::{IntoResponse, Redirect, Response}, routing::{get, post}, BoxError, Extension, Json, Router, }; @@ -22,7 +21,7 @@ use std::{ net::{IpAddr, SocketAddr}, path::PathBuf, sync::Arc, - time::Duration, + time::{Duration, Instant}, }; use sui_config::SUI_CLIENT_CONFIG; use sui_sdk::wallet_context::WalletContext; @@ -31,16 +30,183 @@ use tower_governor::{ governor::GovernorConfigBuilder, key_extractor::GlobalKeyExtractor, GovernorLayer, }; use tower_http::cors::{Any, CorsLayer}; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use uuid::Uuid; use crate::faucet::Faucet; +use dashmap::{mapref::entry::Entry, DashMap}; +use serde::Deserialize; + +use anyhow::ensure; +use once_cell::sync::Lazy; + +const DEFAULT_FAUCET_WEB_APP_URL: &str = "https://faucet.sui.io"; + +static FAUCET_WEB_APP_URL: Lazy = Lazy::new(|| { + std::env::var("FAUCET_WEB_APP_URL") + .ok() + .unwrap_or_else(|| DEFAULT_FAUCET_WEB_APP_URL.to_string()) +}); + +static CLOUDFLARE_TURNSTILE_URL: Lazy> = + Lazy::new(|| std::env::var("CLOUDFLARE_TURNSTILE_URL").ok()); + +static TURNSTILE_SECRET_KEY: Lazy> = + Lazy::new(|| std::env::var("TURNSTILE_SECRET_KEY").ok()); + +/// Keep track of every IP address' requests. +#[derive(Debug)] +struct RequestsManager { + data: Arc>, + reset_time_interval: Duration, + max_requests_per_ip: u64, + cloudflare_turnstile_url: String, + turnstile_secret_key: String, +} + +/// Request's metadata +#[derive(Debug, Clone)] +struct RequestInfo { + /// When the first request from this IP address was made. In case of resetting the IP addresses + /// metadata, this field will be updated with the new current time. + timestamp: Instant, + requests_used: u64, +} + +/// Struct to deserialize token verification response from Cloudflare +#[derive(Deserialize, Debug)] +struct TurnstileValidationResponse { + success: bool, + #[serde(rename = "error-codes")] + error_codes: Vec, +} + +impl RequestsManager { + /// Initialize a new RequestsManager + fn new( + max_requests_per_ip: u64, + reset_time_interval_secs: Duration, + cloudflare_turnstile_url: String, + turnstile_secret_key: String, + ) -> Self { + Self { + data: Arc::new(DashMap::new()), + reset_time_interval: reset_time_interval_secs, + max_requests_per_ip, + cloudflare_turnstile_url, + turnstile_secret_key, + } + } + + /// Validates a turnstile token + /// - against Cloudflare turnstile's server to ensure token was issued by turnstile + /// - against the IP address' request count + async fn validate_turnstile_token( + &self, + addr: SocketAddr, + token: &str, + ) -> Result<(), (StatusCode, FaucetError)> { + let ip = addr.ip(); + let req = reqwest::Client::new(); + let params = [ + ("secret", self.turnstile_secret_key.as_str()), + ("response", token), + ("remoteip", &ip.to_string()), + ]; + + // Make the POST request + let resp = match req + .post(&self.cloudflare_turnstile_url) + .form(¶ms) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + error!("Cloudflare turnstile request failed: {:?}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + FaucetError::Internal(e.to_string()), + )); + } + }; + + // Check if the request was successful. + if !resp.status().is_success() { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + FaucetError::Internal("Verification failed".to_string()), + )); + } + + let body = match resp.json::().await { + Ok(body) => body, + Err(e) => { + error!("Failed to parse token validation response: {:?}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + FaucetError::Internal(e.to_string()), + )); + } + }; + + if !body.success { + return Err(( + StatusCode::BAD_REQUEST, + FaucetError::Internal(format!("Token verification failed: {:?}", body.error_codes)), + )); + } + + match self.data.entry(ip) { + Entry::Vacant(entry) => { + entry.insert(RequestInfo { + timestamp: Instant::now(), + requests_used: 1, + }); + } + + Entry::Occupied(mut entry) => { + let token = entry.get_mut(); + let elapsed = token.timestamp.elapsed(); + + if elapsed >= self.reset_time_interval { + token.timestamp = Instant::now(); + token.requests_used = 1; + } else if token.requests_used >= self.max_requests_per_ip { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + FaucetError::TooManyRequests(format!( + "You can request a new token in {}", + secs_to_human_readable((self.reset_time_interval - elapsed).as_secs()) + )), + )); + } else { + token.requests_used += 1; + } + } + } + + Ok(()) + } + + /// This function iterates through the stored IPs and removes those IP addresses which are now + /// eligible to make new requests. + fn cleanup_expired_tokens(&self) { + // keep only those IP addresses that are still under time limit. + self.data + .retain(|_, info| info.timestamp.elapsed() < self.reset_time_interval); + } +} pub async fn start_faucet( app_state: Arc, concurrency_limit: usize, prometheus_registry: &Registry, ) -> Result<(), anyhow::Error> { + if app_state.config.authenticated { + ensure!(TURNSTILE_SECRET_KEY.is_some() && CLOUDFLARE_TURNSTILE_URL.is_some(), + "Both CLOUDFLARE_TURNSTILE_URL and TURNSTILE_SECRET_KEY env vars must be set for testnet deployment (--authenticated flag was set)"); + } // TODO: restrict access if needed let cors = CorsLayer::new() .allow_methods(vec![Method::GET, Method::POST]) @@ -53,34 +219,61 @@ pub async fn start_faucet( request_buffer_size, max_request_per_second, wal_retry_interval, + replenish_quota_interval_ms, + reset_time_interval_secs, + rate_limiter_cleanup_interval_secs, + max_requests_per_ip, .. } = app_state.config; + let token_manager = Arc::new(RequestsManager::new( + max_requests_per_ip, + Duration::from_secs(reset_time_interval_secs), + CLOUDFLARE_TURNSTILE_URL.as_ref().unwrap().to_string(), + TURNSTILE_SECRET_KEY.as_ref().unwrap().to_string(), + )); + let governor_cfg = Arc::new( GovernorConfigBuilder::default() + .const_per_millisecond(replenish_quota_interval_ms) .burst_size(max_request_per_second as u32) .key_extractor(GlobalKeyExtractor) .finish() .unwrap(), ); - let app = Router::new() - .route("/", get(health)) + // these routes have a more aggressive rate limit to reduce the number of reqs per second as + // per the governor config above. + let global_limited_routes = Router::new() .route("/gas", post(request_gas)) .route("/v1/gas", post(batch_request_gas)) - .route("/v1/status/:task_id", get(request_status)) + .layer(GovernorLayer { + config: governor_cfg.clone(), + }); + + // This has its own rate limiter via the RequestManager + let faucet_web_routes = Router::new().route("/v1/faucet_web_gas", post(batch_faucet_web_gas)); + // Routes with no rate limit + let unrestricted_routes = Router::new() + .route("/", get(redirect)) + .route("/health", get(health)) + .route("/v1/status/:task_id", get(request_status)); + + // Combine all routes + let app = Router::new() + .merge(global_limited_routes) + .merge(unrestricted_routes) + .merge(faucet_web_routes) .layer( ServiceBuilder::new() .layer(HandleErrorLayer::new(handle_error)) .layer(RequestMetricsLayer::new(prometheus_registry)) - .layer(cors) .load_shed() .buffer(request_buffer_size) - .layer(GovernorLayer { - config: governor_cfg, - }) .concurrency_limit(concurrency_limit) .layer(Extension(app_state.clone())) + .layer(Extension(token_manager.clone())) + .layer(cors) .into_inner(), ); @@ -93,10 +286,22 @@ pub async fn start_faucet( } }); + spawn_monitored_task!(async move { + info!("Starting task to clear banned ip addresses."); + loop { + tokio::time::sleep(Duration::from_secs(rate_limiter_cleanup_interval_secs)).await; + token_manager.cleanup_expired_tokens(); + } + }); + let addr = SocketAddr::new(IpAddr::V4(host_ip), port); info!("listening on {}", addr); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await?; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await?; Ok(()) } @@ -105,6 +310,87 @@ async fn health() -> &'static str { "OK" } +/// Redirect to faucet.sui.io/?network if it's testnet/devnet network. For local network, keep the +/// previous behavior to return health status. +async fn redirect(Host(host): Host) -> Response { + let url = FAUCET_WEB_APP_URL.to_string(); + if host.contains("testnet") { + let redirect = Redirect::to(&format!("{url}/?network=testnet")); + redirect.into_response() + } else if host.contains("devnet") { + let redirect = Redirect::to(&format!("{url}/?network=devnet")); + redirect.into_response() + } else { + health().await.into_response() + } +} + +/// Handler for requests coming from the frontend faucet web app. +async fn batch_faucet_web_gas( + headers: HeaderMap, + ConnectInfo(addr): ConnectInfo, + Extension(token_manager): Extension>, + Extension(state): Extension>, + Json(payload): Json, +) -> impl IntoResponse { + if state.config.authenticated { + let Some(token) = headers + .get("X-Turnstile-Token") + .and_then(|v| v.to_str().ok()) + else { + return ( + StatusCode::BAD_REQUEST, + Json(BatchFaucetResponse::from( + FaucetError::MissingTurnstileTokenHeader, + )), + ); + }; + + let validation = token_manager.validate_turnstile_token(addr, token).await; + + if let Err((status_code, faucet_error)) = validation { + return (status_code, Json(BatchFaucetResponse::from(faucet_error))); + } + } + + let FaucetRequest::FixedAmountRequest(request) = payload else { + return ( + StatusCode::BAD_REQUEST, + Json(BatchFaucetResponse::from(FaucetError::Internal( + "Input Error.".to_string(), + ))), + ); + }; + + batch_request_spawn_task(request, state).await +} + +// helper method +async fn batch_request_spawn_task( + request: FixedAmountRequest, + state: Arc, +) -> (StatusCode, Json) { + let result = spawn_monitored_task!(async move { + state + .faucet + .batch_send( + Uuid::new_v4(), + request.recipient, + &vec![state.config.amount; state.config.num_coins], + ) + .await + }) + .await + .unwrap(); + match result { + Ok(v) => (StatusCode::ACCEPTED, Json(BatchFaucetResponse::from(v))), + Err(v) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(BatchFaucetResponse::from(v)), + ), + } +} + /// handler for batch_request_gas requests async fn batch_request_gas( Extension(state): Extension>, @@ -124,32 +410,7 @@ async fn batch_request_gas( }; if state.config.batch_enabled { - let result = spawn_monitored_task!(async move { - state - .faucet - .batch_send( - id, - request.recipient, - &vec![state.config.amount; state.config.num_coins], - ) - .await - }) - .await - .unwrap(); - - match result { - Ok(v) => { - info!(uuid =?id, "Request is successfully served"); - (StatusCode::ACCEPTED, Json(BatchFaucetResponse::from(v))) - } - Err(v) => { - warn!(uuid =?id, "Failed to request gas: {:?}", v); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(BatchFaucetResponse::from(v)), - ) - } - } + batch_request_spawn_task(request, state).await } else { // TODO (jian): remove this feature gate when batch has proven to be baked long enough info!(uuid = ?id, "Falling back to v1 implementation"); @@ -218,6 +479,7 @@ async fn request_gas( // ID for traceability let id = Uuid::new_v4(); info!(uuid = ?id, "Got new gas request."); + let result = match payload { FaucetRequest::FixedAmountRequest(requests) => { // We spawn a tokio task for this such that connection drop will not interrupt @@ -285,3 +547,230 @@ async fn handle_error(error: BoxError) -> impl IntoResponse { Cow::from(format!("Unhandled internal error: {}", error)), ) } + +/// Format seconds to human readable format. +fn secs_to_human_readable(seconds: u64) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let seconds = seconds % 60; + + if hours > 0 { + format!("{}h {}m {}s", hours, minutes, seconds) + } else if minutes > 0 { + format!("{}m {}s", minutes, seconds) + } else { + format!("{}s", seconds) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::net::{IpAddr, Ipv4Addr}; + use std::time::Duration; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const MAX_REQUESTS_PER_IP: u64 = 3; + const RESET_TIME_INTERVAL: Duration = Duration::from_secs(5); + + async fn setup_mock_cloudflare() -> MockServer { + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "success": true, "error-codes": [] })), + ) + .mount(&mock_server) + .await; + + mock_server + } + + #[tokio::test] + async fn test_token_validation_and_limits() { + // Start mock server + let mock_server = setup_mock_cloudflare().await; + let manager = RequestsManager::new( + MAX_REQUESTS_PER_IP, + RESET_TIME_INTERVAL, + mock_server.uri(), + "test_secret".to_string(), + ); + let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080); + let token = "test_token"; + + // First request should succeed + let result = manager.validate_turnstile_token(ip, token).await; + assert!(result.is_ok()); + + // Use up remaining requests + for _ in 1..manager.max_requests_per_ip { + let result = manager.validate_turnstile_token(ip, token).await; + assert!(result.is_ok()); + } + + // Next request should fail due to limit + let result = manager.validate_turnstile_token(ip, token).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_token_reset_after_interval() { + let mock_server = setup_mock_cloudflare().await; + let manager = RequestsManager::new( + MAX_REQUESTS_PER_IP, + RESET_TIME_INTERVAL, + mock_server.uri(), + "test_secret".to_string(), + ); + + let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080); + let token = "test_token"; + + // Use up all requests + for _ in 0..manager.max_requests_per_ip { + let result = manager.validate_turnstile_token(ip, token).await; + assert!(result.is_ok()); + } + + // Try one more, it should fail + let result = manager.validate_turnstile_token(ip, token).await; + assert!(result.is_err()); + assert!(result.unwrap_err().0 == StatusCode::TOO_MANY_REQUESTS); + assert!(!manager.data.is_empty()); + + tokio::time::sleep(RESET_TIME_INTERVAL + Duration::from_secs(3)).await; + // Trigger cleanup + manager.cleanup_expired_tokens(); + + // Should be able to make new requests + let result = manager.validate_turnstile_token(ip, token).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_invalid_token_response() { + let mock_server = MockServer::start().await; + + // Setup mock for invalid token + Mock::given(method("POST")) + .and(path("/siteverify")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "success": false, + "error-codes": ["invalid-input-response"] + }))) + .mount(&mock_server) + .await; + + let manager = RequestsManager::new( + MAX_REQUESTS_PER_IP, + RESET_TIME_INTERVAL, + mock_server.uri(), + "test_secret".to_string(), + ); + let ip = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080); + let token = "invalid_token"; + + let result = manager.validate_turnstile_token(ip, token).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_concurrent_ip_requests() { + let mock_server = setup_mock_cloudflare().await; + let manager = Arc::new(RequestsManager::new( + MAX_REQUESTS_PER_IP, + RESET_TIME_INTERVAL, + mock_server.uri(), + "test_secret".to_string(), + )); + + // Create 10 different IP addresses + let ips: Vec = (0..10) + .map(|i| SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, i as u8)), 8080)) + .collect(); + + let token = "test_token"; + + // Spawn tasks for each IP to make requests concurrently + let mut handles = vec![]; + + for (idx, &ip) in ips.iter().enumerate() { + let manager = manager.clone(); + let handle = tokio::spawn(async move { + // Add some random delay to simulate real-world conditions + tokio::time::sleep(Duration::from_millis(idx as u64 * 50)).await; + + let mut results = vec![]; + // Each IP tries to make MAX_REQUESTS_PER_IP + 1 requests + for _ in 0..=MAX_REQUESTS_PER_IP { + let result = manager.validate_turnstile_token(ip, token).await; + results.push(result); + } + (ip, results) + }); + handles.push(handle); + } + + // Wait for all tasks to complete and check results + let all_results = futures::future::join_all(handles).await; + + for result in all_results { + let (ip, results) = result.unwrap(); + + // First MAX_REQUESTS_PER_IP requests should succeed + for (idx, _) in results + .iter() + .enumerate() + .take(MAX_REQUESTS_PER_IP as usize) + { + assert!( + results[idx].is_ok(), + "Request {} for IP {} should succeed", + idx, + ip + ); + } + + // The last request (MAX_REQUESTS_PER_IP + 1) should fail + assert!( + results[MAX_REQUESTS_PER_IP as usize].is_err(), + "Request {} for IP {} should fail", + MAX_REQUESTS_PER_IP, + ip + ); + } + + // Verify the data in the DashMap + assert_eq!(manager.data.len(), 10, "Should have 10 IPs in the map"); + + for info in manager.data.iter() { + assert_eq!( + info.requests_used, MAX_REQUESTS_PER_IP, + "Each IP should have used exactly MAX_REQUESTS_PER_IP requests" + ); + } + } + + #[test] + fn test_secs_to_human_readable() { + // Test seconds only + assert_eq!(secs_to_human_readable(45), "45s"); + assert_eq!(secs_to_human_readable(1), "1s"); + + // Test minutes and seconds + assert_eq!(secs_to_human_readable(65), "1m 5s"); + assert_eq!(secs_to_human_readable(3599), "59m 59s"); + + // Test hours, minutes, and seconds + assert_eq!(secs_to_human_readable(3600), "1h 0m 0s"); + assert_eq!(secs_to_human_readable(3661), "1h 1m 1s"); + assert_eq!(secs_to_human_readable(7384), "2h 3m 4s"); + + // Test edge case + assert_eq!(secs_to_human_readable(0), "0s"); + } +} diff --git a/crates/sui/src/client_commands.rs b/crates/sui/src/client_commands.rs index 368e22328c4c0..f7bd97a208ae2 100644 --- a/crates/sui/src/client_commands.rs +++ b/crates/sui/src/client_commands.rs @@ -665,7 +665,7 @@ impl OptsWithGas { } } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Debug)] struct FaucetResponse { error: Option, } @@ -1444,6 +1444,10 @@ impl SuiClientCommands { SuiClientCommands::Faucet { address, url } => { let address = get_identity_address(address, context)?; let url = if let Some(url) = url { + ensure!( + !url.starts_with("https://faucet.testnet.sui.io"), + "For testnet tokens, please use the Web UI: https://faucet.sui.io/?address={address}" + ); url } else { let active_env = context.config.get_active_env(); @@ -1451,7 +1455,9 @@ impl SuiClientCommands { if let Ok(env) = active_env { let network = match env.rpc.as_str() { SUI_DEVNET_URL => "https://faucet.devnet.sui.io/v1/gas", - SUI_TESTNET_URL => "https://faucet.testnet.sui.io/v1/gas", + SUI_TESTNET_URL => { + bail!("For testnet tokens, please use the Web UI: https://faucet.sui.io/?address={address}"); + } SUI_LOCAL_NETWORK_URL | SUI_LOCAL_NETWORK_URL_0 => "http://127.0.0.1:9123/gas", _ => bail!("Cannot recognize the active network. Please provide the gas faucet full URL.") }; @@ -2569,6 +2575,12 @@ pub async fn request_tokens_from_faucet( println!("Request successful. It can take up to 1 minute to get the coin. Run sui client gas to check your gas coins."); } } + StatusCode::BAD_REQUEST => { + let faucet_resp: FaucetResponse = resp.json().await?; + if let Some(err) = faucet_resp.error { + bail!("Faucet request was unsuccessful. {err}"); + } + } StatusCode::TOO_MANY_REQUESTS => { bail!("Faucet service received too many requests from this IP address. Please try again after 60 minutes."); }