From 8a5f697931ac530f1fd738688dba73bad1971504 Mon Sep 17 00:00:00 2001 From: stefan-mysten <135084671+stefan-mysten@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:10:00 -0800 Subject: [PATCH] [faucet] updates to enable faucet web app (#20846) ## Description This PR updates the faucet service to support requests from Faucet Web, which for testnet are authenticated via a token. It keeps track of each ip address and the number of requests, and limits to a predefined number of requests per a time window. In authenticated mode, it expects that requests go through `/v1/faucet_web_gas`. If requests go through the original `/v1/gas`, they will be under strict rate limit. In addition, it adds a new route `/health`, and moves the logic of `/` to this new route. The old route `/` has a redirect logic to the `faucet.sui.io` web app for requesting tokens. Finally, the CLI is updated to error if `sui client faucet` is called on the testnet network, and provides a message with the url to open to request tokens. ## Test plan Added tests for the logic on cleaning up the list of banned IPs once the reset time passes. `cargo test -p sui-faucet -- server` --- ## Release notes Check each box that your changes affect. If none of the boxes relate to your changes, release notes aren't required. For each box you select, include information after the relevant heading that describes the impact of your changes that a user might notice and any actions they must take to implement updates. - [ ] Protocol: - [ ] Nodes (Validators and Full nodes): - [ ] gRPC: - [ ] JSON-RPC: - [ ] GraphQL: - [x] CLI: `sui client faucet` will now instruct users to use the Faucet Web App (faucet.sui.io) to request testnet tokens. For devnet/localhost, behaviour is unchanged. - [ ] Rust SDK: --- Cargo.lock | 5 + crates/sui-faucet/Cargo.toml | 5 + crates/sui-faucet/src/errors.rs | 9 + crates/sui-faucet/src/faucet/mod.rs | 31 ++ crates/sui-faucet/src/metrics.rs | 5 +- crates/sui-faucet/src/server.rs | 571 ++++++++++++++++++++++++++-- crates/sui/src/client_commands.rs | 16 +- 7 files changed, 598 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 35522b83bcad0..0e638fe9bffbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13681,15 +13681,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", @@ -13710,6 +13714,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."); }