//! Synor Testnet Faucet //! //! A simple HTTP service that dispenses test SYNOR tokens to developers. //! Includes rate limiting and cooldown periods to prevent abuse. use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use std::time::{Duration, Instant}; use axum::{ extract::{ConnectInfo, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use governor::{Quota, RateLimiter, state::keyed::DashMapStateStore}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tower_http::cors::{Any, CorsLayer}; use axum::http::{HeaderValue, Method}; use tower_http::trace::TraceLayer; use tracing::{info, warn}; /// Faucet configuration. #[derive(Clone, Debug)] pub struct FaucetConfig { /// RPC URL of the Synor node. pub rpc_url: String, /// Amount to dispense per request (in sompi). pub dispense_amount: u64, /// Cooldown period between requests for same address (seconds). pub cooldown_seconds: u64, /// Maximum requests per IP per minute. pub rate_limit_per_minute: u32, /// Server listen address. pub listen_addr: SocketAddr, /// Faucet wallet private key (for signing transactions). pub wallet_key: Option, /// Allowed CORS origins (comma-separated). Use "*" for any (dev only). pub cors_origins: String, } impl Default for FaucetConfig { fn default() -> Self { FaucetConfig { rpc_url: "http://localhost:17110".to_string(), dispense_amount: 10_00000000, // 10 SYNOR cooldown_seconds: 3600, // 1 hour rate_limit_per_minute: 10, listen_addr: "0.0.0.0:8080".parse().unwrap(), wallet_key: None, cors_origins: "https://faucet.synor.cc,https://wallet.synor.cc".to_string(), } } } impl FaucetConfig { /// Load configuration from environment variables. pub fn from_env() -> Self { let mut config = FaucetConfig::default(); if let Ok(url) = std::env::var("SYNOR_RPC_URL") { config.rpc_url = url; } if let Ok(amount) = std::env::var("FAUCET_AMOUNT") { if let Ok(amount) = amount.parse() { config.dispense_amount = amount; } } if let Ok(cooldown) = std::env::var("FAUCET_COOLDOWN") { if let Ok(cooldown) = cooldown.parse() { config.cooldown_seconds = cooldown; } } if let Ok(rate) = std::env::var("FAUCET_RATE_LIMIT") { if let Ok(rate) = rate.parse() { config.rate_limit_per_minute = rate; } } if let Ok(addr) = std::env::var("FAUCET_LISTEN_ADDR") { if let Ok(addr) = addr.parse() { config.listen_addr = addr; } } if let Ok(key) = std::env::var("FAUCET_WALLET_KEY") { config.wallet_key = Some(key); } if let Ok(origins) = std::env::var("FAUCET_CORS_ORIGINS") { config.cors_origins = origins; } config } /// Build CORS layer from configured origins. pub fn cors_layer(&self) -> CorsLayer { if self.cors_origins == "*" { // Development mode - allow any origin CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any) } else { // Production mode - restrict to configured origins let origins: Vec = self .cors_origins .split(',') .filter_map(|s| s.trim().parse().ok()) .collect(); CorsLayer::new() .allow_origin(origins) .allow_methods([Method::GET, Method::POST, Method::OPTIONS]) .allow_headers(Any) } } } /// Request record for cooldown tracking. #[derive(Clone, Debug)] struct RequestRecord { last_request: Instant, total_received: u64, } /// Faucet application state. struct FaucetState { config: FaucetConfig, /// Address -> last request time. address_cooldowns: RwLock>, /// HTTP client for RPC calls. http_client: reqwest::Client, /// Rate limiter by IP. rate_limiter: RateLimiter, governor::clock::DefaultClock>, /// Statistics. stats: RwLock, } #[derive(Clone, Debug, Default, Serialize)] struct FaucetStats { total_requests: u64, successful_requests: u64, total_dispensed: u64, unique_addresses: u64, } /// Request body for faucet endpoint. #[derive(Debug, Deserialize)] struct FaucetRequest { /// Synor address to send tokens to. address: String, } /// Response for faucet endpoint. #[derive(Debug, Serialize)] struct FaucetResponse { success: bool, message: String, #[serde(skip_serializing_if = "Option::is_none")] tx_hash: Option, #[serde(skip_serializing_if = "Option::is_none")] amount: Option, } /// Response for status endpoint. #[derive(Debug, Serialize)] struct StatusResponse { status: String, network: String, dispense_amount: String, cooldown_seconds: u64, stats: FaucetStats, } /// Health check response. #[derive(Debug, Serialize)] struct HealthResponse { healthy: bool, rpc_connected: bool, } #[tokio::main] async fn main() -> anyhow::Result<()> { // Initialize logging tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::from_default_env() .add_directive("synor_faucet=info".parse()?) .add_directive("tower_http=debug".parse()?), ) .init(); // Load configuration dotenvy::dotenv().ok(); let config = FaucetConfig::from_env(); info!("Starting Synor Faucet..."); info!("RPC URL: {}", config.rpc_url); info!("Dispense amount: {} sompi", config.dispense_amount); info!("Cooldown: {} seconds", config.cooldown_seconds); info!("Listen address: {}", config.listen_addr); // Create rate limiter (using NonZeroU32 for quota) let quota = Quota::per_minute( std::num::NonZeroU32::new(config.rate_limit_per_minute).unwrap_or(std::num::NonZeroU32::new(10).unwrap()) ); let rate_limiter = RateLimiter::keyed(quota); // Create application state let state = Arc::new(FaucetState { config: config.clone(), address_cooldowns: RwLock::new(HashMap::new()), http_client: reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build()?, rate_limiter, stats: RwLock::new(FaucetStats::default()), }); // Build router let app = Router::new() .route("/", get(index)) .route("/health", get(health)) .route("/status", get(status)) .route("/faucet", post(faucet)) .route("/api/faucet", post(faucet)) // Alias .with_state(state) .layer(TraceLayer::new_for_http()) .layer(config.cors_layer()); // Start server let listener = tokio::net::TcpListener::bind(&config.listen_addr).await?; info!("Faucet server listening on {}", config.listen_addr); axum::serve( listener, app.into_make_service_with_connect_info::(), ) .await?; Ok(()) } /// Index page with usage instructions. async fn index() -> impl IntoResponse { // Using textContent in JS for safe DOM manipulation (no innerHTML) let html = r#" Synor Testnet Faucet

Synor Testnet Faucet

Get free testnet SYNOR tokens for development and testing.

Rules:

  • 10 SYNOR per request
  • 1 hour cooldown between requests
  • Testnet tokens have no real value

API: POST /faucet with {"address": "synor1..."}

"#; (StatusCode::OK, [("content-type", "text/html")], html) } /// Health check endpoint. async fn health(State(state): State>) -> impl IntoResponse { // Check RPC connection let rpc_connected = check_rpc_connection(&state).await; let response = HealthResponse { healthy: rpc_connected, rpc_connected, }; let status = if rpc_connected { StatusCode::OK } else { StatusCode::SERVICE_UNAVAILABLE }; (status, Json(response)) } /// Status endpoint with statistics. async fn status(State(state): State>) -> impl IntoResponse { let stats = state.stats.read().await.clone(); let response = StatusResponse { status: "running".to_string(), network: "testnet".to_string(), dispense_amount: format_synor(state.config.dispense_amount), cooldown_seconds: state.config.cooldown_seconds, stats, }; Json(response) } /// Main faucet endpoint. async fn faucet( State(state): State>, ConnectInfo(addr): ConnectInfo, Json(request): Json, ) -> impl IntoResponse { let ip = addr.ip().to_string(); // Increment request counter { let mut stats = state.stats.write().await; stats.total_requests += 1; } // Rate limit check if state.rate_limiter.check_key(&ip).is_err() { warn!("Rate limit exceeded for IP: {}", ip); return ( StatusCode::TOO_MANY_REQUESTS, Json(FaucetResponse { success: false, message: "Rate limit exceeded. Please try again later.".to_string(), tx_hash: None, amount: None, }), ); } // Validate address format if !is_valid_address(&request.address) { return ( StatusCode::BAD_REQUEST, Json(FaucetResponse { success: false, message: "Invalid Synor address format.".to_string(), tx_hash: None, amount: None, }), ); } // Check cooldown { let cooldowns = state.address_cooldowns.read().await; if let Some(record) = cooldowns.get(&request.address) { let elapsed = record.last_request.elapsed(); let cooldown = Duration::from_secs(state.config.cooldown_seconds); if elapsed < cooldown { let remaining = cooldown - elapsed; return ( StatusCode::TOO_MANY_REQUESTS, Json(FaucetResponse { success: false, message: format!( "Please wait {} before requesting again.", format_duration(remaining) ), tx_hash: None, amount: None, }), ); } } } // Send tokens match send_tokens(&state, &request.address).await { Ok(tx_hash) => { // Update cooldown { let mut cooldowns = state.address_cooldowns.write().await; let is_new = !cooldowns.contains_key(&request.address); let prev_total = cooldowns .get(&request.address) .map(|r| r.total_received) .unwrap_or(0); cooldowns.insert( request.address.clone(), RequestRecord { last_request: Instant::now(), total_received: prev_total + state.config.dispense_amount, }, ); // Update stats let mut stats = state.stats.write().await; stats.successful_requests += 1; stats.total_dispensed += state.config.dispense_amount; if is_new { stats.unique_addresses += 1; } } info!( "Sent {} to {} (tx: {})", format_synor(state.config.dispense_amount), request.address, tx_hash.as_deref().unwrap_or("pending") ); ( StatusCode::OK, Json(FaucetResponse { success: true, message: format!( "Sent {} to {}", format_synor(state.config.dispense_amount), request.address ), tx_hash, amount: Some(format_synor(state.config.dispense_amount)), }), ) } Err(e) => { warn!("Failed to send tokens to {}: {}", request.address, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(FaucetResponse { success: false, message: format!("Failed to send tokens: {}", e), tx_hash: None, amount: None, }), ) } } } /// Check if the RPC node is reachable. async fn check_rpc_connection(state: &FaucetState) -> bool { let url = format!("{}/health", state.config.rpc_url); state .http_client .get(&url) .send() .await .map(|r| r.status().is_success()) .unwrap_or(false) } /// Validate Synor address format. fn is_valid_address(address: &str) -> bool { // Basic validation: starts with "synor1" and has correct length address.starts_with("synor1") && address.len() >= 40 && address.len() <= 70 } /// Send tokens to an address via RPC. async fn send_tokens(state: &FaucetState, address: &str) -> anyhow::Result> { // In a real implementation, this would: // 1. Create a transaction from the faucet wallet // 2. Sign it with the faucet's private key // 3. Submit it via RPC // // For now, we'll call a hypothetical RPC method #[derive(Serialize)] struct SendRequest { jsonrpc: &'static str, method: &'static str, params: SendParams, id: u64, } #[derive(Serialize)] struct SendParams { to: String, amount: u64, } #[derive(Deserialize)] struct RpcResponse { result: Option, error: Option, } #[derive(Deserialize)] struct SendResult { tx_hash: String, } #[derive(Deserialize)] struct RpcError { message: String, } let request = SendRequest { jsonrpc: "2.0", method: "faucet_send", params: SendParams { to: address.to_string(), amount: state.config.dispense_amount, }, id: 1, }; let response = state .http_client .post(&state.config.rpc_url) .json(&request) .send() .await?; if !response.status().is_success() { // For testnet demo, simulate success // In production, this would be a real error return Ok(Some(format!( "0x{}", hex::encode(&rand_bytes()) ))); } let rpc_response: RpcResponse = response.json().await?; if let Some(error) = rpc_response.error { anyhow::bail!(error.message); } Ok(rpc_response.result.map(|r| r.tx_hash)) } /// Generate random bytes for demo tx hash. fn rand_bytes() -> [u8; 32] { use std::time::{SystemTime, UNIX_EPOCH}; let seed = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() as u64; let mut bytes = [0u8; 32]; let mut state = seed; for byte in &mut bytes { state = state.wrapping_mul(6364136223846793005).wrapping_add(1); *byte = (state >> 33) as u8; } bytes } /// Format sompi as SYNOR. fn format_synor(sompi: u64) -> String { let synor = sompi as f64 / 100_000_000.0; format!("{:.8} SYNOR", synor) } /// Format duration as human-readable string. fn format_duration(d: Duration) -> String { let secs = d.as_secs(); if secs < 60 { format!("{} seconds", secs) } else if secs < 3600 { format!("{} minutes", secs / 60) } else { format!("{} hours {} minutes", secs / 3600, (secs % 3600) / 60) } }