diff --git a/crates/bitcell-admin/src/api/faucet.rs b/crates/bitcell-admin/src/api/faucet.rs new file mode 100644 index 0000000..93dee6a --- /dev/null +++ b/crates/bitcell-admin/src/api/faucet.rs @@ -0,0 +1,203 @@ +//! Faucet API endpoints + +use axum::{ + extract::{State, Json}, + response::IntoResponse, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use crate::{AppState, faucet::{FaucetError, FaucetRequest as ServiceRequest}}; + +/// Faucet request +#[derive(Debug, Deserialize)] +pub struct FaucetRequest { + /// Recipient address + pub address: String, + /// CAPTCHA response token + pub captcha_response: Option, +} + +/// Faucet response +#[derive(Debug, Serialize)] +pub struct FaucetResponse { + pub success: bool, + pub message: String, + pub tx_hash: Option, + pub amount: Option, +} + +/// Faucet info response +#[derive(Debug, Serialize)] +pub struct FaucetInfoResponse { + pub balance: u64, + pub amount_per_request: u64, + pub rate_limit_seconds: u64, + pub max_requests_per_day: usize, + pub require_captcha: bool, +} + +/// Request testnet tokens +pub async fn request_tokens( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let faucet = match &state.faucet { + Some(f) => f, + None => return ( + StatusCode::NOT_FOUND, + Json(FaucetResponse { + success: false, + message: "Faucet not enabled".to_string(), + tx_hash: None, + amount: None, + }) + ).into_response(), + }; + + match faucet.process_request( + &req.address, + req.captcha_response.as_deref(), + ).await { + Ok(request) => { + Json(FaucetResponse { + success: true, + message: format!( + "Successfully sent {} tokens to {}", + request.amount, request.address + ), + tx_hash: Some(request.tx_hash), + amount: Some(request.amount), + }).into_response() + } + Err(e) => { + let (status, message) = match e { + FaucetError::RateLimited(seconds) => ( + StatusCode::TOO_MANY_REQUESTS, + format!("Rate limit exceeded. Try again in {} seconds", seconds) + ), + FaucetError::InvalidAddress(msg) => ( + StatusCode::BAD_REQUEST, + msg + ), + FaucetError::InvalidCaptcha => ( + StatusCode::BAD_REQUEST, + "Invalid CAPTCHA response".to_string() + ), + FaucetError::InsufficientBalance => ( + StatusCode::SERVICE_UNAVAILABLE, + "Faucet balance too low. Please contact administrator.".to_string() + ), + _ => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to process request: {}", e) + ), + }; + + ( + status, + Json(FaucetResponse { + success: false, + message, + tx_hash: None, + amount: None, + }) + ).into_response() + } + } +} + +/// Get faucet information +pub async fn get_info( + State(state): State>, +) -> impl IntoResponse { + let faucet = match &state.faucet { + Some(f) => f, + None => return (StatusCode::NOT_FOUND, "Faucet not enabled").into_response(), + }; + + let config = faucet.get_config(); + + let balance = match faucet.get_balance().await { + Ok(b) => b, + Err(e) => return ( + StatusCode::SERVICE_UNAVAILABLE, + format!("Failed to fetch faucet balance: {}", e) + ).into_response(), + }; + + Json(FaucetInfoResponse { + balance, + amount_per_request: config.amount_per_request, + rate_limit_seconds: config.rate_limit_seconds, + max_requests_per_day: config.max_requests_per_day, + require_captcha: config.require_captcha, + }).into_response() +} + +/// Get recent faucet requests +pub async fn get_history( + State(state): State>, +) -> impl IntoResponse { + let faucet = match &state.faucet { + Some(f) => f, + None => return (StatusCode::NOT_FOUND, "Faucet not enabled").into_response(), + }; + + let history = faucet.get_history(50); + Json(history).into_response() +} + +/// Get faucet statistics +pub async fn get_stats( + State(state): State>, +) -> impl IntoResponse { + let faucet = match &state.faucet { + Some(f) => f, + None => return (StatusCode::NOT_FOUND, "Faucet not enabled").into_response(), + }; + + let stats = faucet.get_stats(); + Json(stats).into_response() +} + +/// Check if address can request tokens +#[derive(Debug, Deserialize)] +pub struct CheckEligibilityRequest { + pub address: String, +} + +#[derive(Debug, Serialize)] +pub struct CheckEligibilityResponse { + pub eligible: bool, + pub message: String, + pub retry_after_seconds: Option, +} + +pub async fn check_eligibility( + State(state): State>, + Json(req): Json, +) -> impl IntoResponse { + let faucet = match &state.faucet { + Some(f) => f, + None => return (StatusCode::NOT_FOUND, "Faucet not enabled").into_response(), + }; + + match faucet.check_rate_limit(&req.address) { + Ok(_) => Json(CheckEligibilityResponse { + eligible: true, + message: "Address is eligible for faucet request".to_string(), + retry_after_seconds: None, + }).into_response(), + Err(FaucetError::RateLimited(seconds)) => Json(CheckEligibilityResponse { + eligible: false, + message: format!("Rate limit active. Try again in {} seconds", seconds), + retry_after_seconds: Some(seconds), + }).into_response(), + Err(e) => Json(CheckEligibilityResponse { + eligible: false, + message: e.to_string(), + retry_after_seconds: None, + }).into_response(), + } +} diff --git a/crates/bitcell-admin/src/api/mod.rs b/crates/bitcell-admin/src/api/mod.rs index 616bfa4..bfef0fc 100644 --- a/crates/bitcell-admin/src/api/mod.rs +++ b/crates/bitcell-admin/src/api/mod.rs @@ -8,6 +8,7 @@ pub mod test; pub mod setup; pub mod blocks; pub mod wallet; +pub mod faucet; pub mod auth; use std::collections::HashMap; diff --git a/crates/bitcell-admin/src/faucet.rs b/crates/bitcell-admin/src/faucet.rs new file mode 100644 index 0000000..1bc4abf --- /dev/null +++ b/crates/bitcell-admin/src/faucet.rs @@ -0,0 +1,735 @@ +//! Testnet Faucet Service +//! +//! Provides token distribution for testnet with: +//! - Rate limiting per address +//! - Request tracking and audit logging +//! - CAPTCHA verification support +//! - Secure wallet management + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Standard gas limit for simple token transfer +const STANDARD_TRANSFER_GAS: u64 = 21000; + +/// Faucet errors +#[derive(Debug, Error)] +pub enum FaucetError { + #[error("Rate limit exceeded. Try again in {0} seconds")] + RateLimited(u64), + #[error("Invalid address format: {0}")] + InvalidAddress(String), + #[error("Faucet balance too low")] + InsufficientBalance, + #[error("Transaction failed: {0}")] + TransactionFailed(String), + #[error("Invalid CAPTCHA")] + InvalidCaptcha, + #[error("Configuration error: {0}")] + ConfigError(String), +} + +/// Faucet configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaucetConfig { + /// Amount to send per request (in smallest units) + pub amount_per_request: u64, + /// Minimum time between requests from same address (seconds) + pub rate_limit_seconds: u64, + /// Maximum requests per address per day + pub max_requests_per_day: usize, + /// Faucet private key (hex string) + pub private_key: String, + /// Node RPC host + pub node_rpc_host: String, + /// Node RPC port + pub node_rpc_port: u16, + /// Enable CAPTCHA verification + pub require_captcha: bool, + /// Maximum balance an address can have to receive funds (anti-abuse) + pub max_recipient_balance: Option, +} + +impl Default for FaucetConfig { + fn default() -> Self { + Self { + amount_per_request: 1_000_000_000, // 1 CELL in smallest units + rate_limit_seconds: 3600, // 1 hour + max_requests_per_day: 5, + private_key: String::new(), + node_rpc_host: "127.0.0.1".to_string(), + node_rpc_port: 8545, + require_captcha: false, // Disabled by default (not implemented) + max_recipient_balance: Some(10_000_000_000), // 10 CELL max balance + } + } +} + +impl FaucetConfig { + /// Validate the configuration fields + pub fn validate(&self) -> Result<(), FaucetError> { + if self.amount_per_request == 0 { + return Err(FaucetError::ConfigError( + "amount_per_request must be greater than 0".to_string() + )); + } + if self.rate_limit_seconds == 0 { + return Err(FaucetError::ConfigError( + "rate_limit_seconds must be greater than 0".to_string() + )); + } + if self.max_requests_per_day == 0 { + return Err(FaucetError::ConfigError( + "max_requests_per_day must be greater than 0".to_string() + )); + } + if self.private_key.is_empty() { + return Err(FaucetError::ConfigError( + "private_key must be set".to_string() + )); + } + // Validate private key format (hex string, with or without 0x prefix) + let key = self.private_key.trim_start_matches("0x"); + if key.len() != 64 || !key.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(FaucetError::ConfigError( + "private_key must be a 64-character hex string (with or without 0x prefix)".to_string() + )); + } + if self.require_captcha { + return Err(FaucetError::ConfigError( + "CAPTCHA verification is not implemented. Set require_captcha to false.".to_string() + )); + } + Ok(()) + } +} + +/// Request history entry +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaucetRequest { + pub address: String, + pub amount: u64, + pub timestamp: u64, + pub tx_hash: String, + pub status: RequestStatus, +} + +/// Status of a faucet request +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum RequestStatus { + Pending, + Completed, + Failed, +} + +/// Rate limit tracking +#[derive(Debug, Clone)] +struct RateLimitInfo { + last_request: u64, + requests_today: Vec, +} + +/// Maximum number of requests to keep in history +const MAX_HISTORY_SIZE: usize = 10_000; + +/// Threshold for cleaning up old rate limit entries (30 days) +const CLEANUP_THRESHOLD_SECONDS: u64 = 30 * 86400; + +/// Faucet service +pub struct FaucetService { + config: Arc>, + rate_limits: Arc>>, + request_history: Arc>>, +} + +impl FaucetService { + /// Create a new faucet service + pub fn new(config: FaucetConfig) -> Result { + // Validate configuration + config.validate()?; + + Ok(Self { + config: Arc::new(RwLock::new(config)), + rate_limits: Arc::new(RwLock::new(HashMap::new())), + request_history: Arc::new(RwLock::new(Vec::new())), + }) + } + + /// Get current configuration + pub fn get_config(&self) -> FaucetConfig { + self.config.read().clone() + } + + /// Update configuration + pub fn update_config(&self, config: FaucetConfig) -> Result<(), FaucetError> { + // Validate before updating + config.validate()?; + *self.config.write() = config; + Ok(()) + } + + /// Check if address can request tokens (with atomic check-and-set) + fn check_and_record_rate_limit(&self, address: &str, timestamp: u64) -> Result<(), FaucetError> { + let config = self.config.read(); + let mut rate_limits = self.rate_limits.write(); + + // Get or create rate limit info + let info = rate_limits.entry(address.to_string()).or_insert_with(|| { + RateLimitInfo { + last_request: 0, + requests_today: Vec::new(), + } + }); + + // Check time-based rate limit + let elapsed = timestamp.saturating_sub(info.last_request); + if elapsed < config.rate_limit_seconds && info.last_request > 0 { + let remaining = config.rate_limit_seconds - elapsed; + return Err(FaucetError::RateLimited(remaining)); + } + + // Check daily request limit + let today_start = timestamp.checked_div(86400) + .and_then(|d| d.checked_mul(86400)) + .unwrap_or(timestamp); + + let requests_today: Vec<_> = info.requests_today + .iter() + .filter(|&&t| t >= today_start) + .copied() + .collect(); + + if requests_today.len() >= config.max_requests_per_day { + let next_day = today_start + 86400; + let remaining = next_day - timestamp; + return Err(FaucetError::RateLimited(remaining)); + } + + // Record the request atomically + info.last_request = timestamp; + info.requests_today.retain(|&t| t >= today_start); + info.requests_today.push(timestamp); + + // Cleanup old entries periodically (every 100 requests) + if rate_limits.len() % 100 == 0 { + rate_limits.retain(|_, info| { + timestamp.saturating_sub(info.last_request) < CLEANUP_THRESHOLD_SECONDS + }); + } + + Ok(()) + } + + /// Check if address can request tokens (read-only check) + pub fn check_rate_limit(&self, address: &str) -> Result<(), FaucetError> { + let config = self.config.read(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let rate_limits = self.rate_limits.read(); + + if let Some(info) = rate_limits.get(address) { + // Check time-based rate limit + let elapsed = now.saturating_sub(info.last_request); + if elapsed < config.rate_limit_seconds { + let remaining = config.rate_limit_seconds - elapsed; + return Err(FaucetError::RateLimited(remaining)); + } + + // Check daily request limit + let today_start = now.checked_div(86400) + .and_then(|d| d.checked_mul(86400)) + .unwrap_or(now); + let requests_today: Vec<_> = info.requests_today + .iter() + .filter(|&&t| t >= today_start) + .collect(); + + if requests_today.len() >= config.max_requests_per_day { + let next_day = today_start + 86400; + let remaining = next_day - now; + return Err(FaucetError::RateLimited(remaining)); + } + } + + Ok(()) + } + + /// Get faucet balance + pub async fn get_balance(&self) -> Result { + let config = self.config.read().clone(); + let rpc_url = format!("http://{}:{}/rpc", config.node_rpc_host, config.node_rpc_port); + + // Get faucet address from private key + let faucet_address = self.get_faucet_address(&config.private_key)?; + + let client = reqwest::Client::new(); + let rpc_req = serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_getBalance", + "params": [faucet_address, "latest"], + "id": 1 + }); + + let resp = client + .post(&rpc_url) + .json(&rpc_req) + .send() + .await + .map_err(|e| FaucetError::TransactionFailed(e.to_string()))?; + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| FaucetError::TransactionFailed(e.to_string()))?; + + if let Some(result) = json.get("result").and_then(|v| v.as_str()) { + let balance = u64::from_str_radix(result.trim_start_matches("0x"), 16) + .map_err(|e| FaucetError::TransactionFailed( + format!("Failed to parse faucet balance: {}", e) + ))?; + Ok(balance) + } else { + Err(FaucetError::TransactionFailed( + "Invalid RPC response for faucet balance: missing result field".to_string() + )) + } + } + + /// Get recipient balance + async fn get_recipient_balance(&self, address: &str) -> Result { + let config = self.config.read().clone(); + let rpc_url = format!("http://{}:{}/rpc", config.node_rpc_host, config.node_rpc_port); + + let client = reqwest::Client::new(); + let rpc_req = serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_getBalance", + "params": [address, "latest"], + "id": 1 + }); + + let resp = client + .post(&rpc_url) + .json(&rpc_req) + .send() + .await + .map_err(|e| FaucetError::TransactionFailed(e.to_string()))?; + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| FaucetError::TransactionFailed(e.to_string()))?; + + if let Some(result) = json.get("result").and_then(|v| v.as_str()) { + let balance = u64::from_str_radix(result.trim_start_matches("0x"), 16) + .map_err(|e| FaucetError::TransactionFailed( + format!("Failed to parse recipient balance: {}", e) + ))?; + Ok(balance) + } else { + Err(FaucetError::TransactionFailed( + "Invalid RPC response for recipient balance: missing result field".to_string() + )) + } + } + + /// Process faucet request + pub async fn process_request( + &self, + address: &str, + captcha_response: Option<&str>, + ) -> Result { + // Validate address format + self.validate_address(address)?; + + // Check CAPTCHA if required + let config = self.config.read().clone(); + if config.require_captcha { + // CAPTCHA validation is not implemented. Return error to prevent false security. + return Err(FaucetError::ConfigError( + "CAPTCHA verification is not implemented. Disable require_captcha or implement verification first.".to_string() + )); + } + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Atomically check and record rate limit (prevents TOCTOU race condition) + self.check_and_record_rate_limit(address, timestamp)?; + + // Check recipient balance if configured + if let Some(max_balance) = config.max_recipient_balance { + let recipient_balance = self.get_recipient_balance(address).await?; + if recipient_balance >= max_balance { + return Err(FaucetError::TransactionFailed( + format!("Recipient balance ({}) exceeds maximum allowed ({})", + recipient_balance, max_balance) + )); + } + } + + // Check faucet balance + let balance = self.get_balance().await?; + if balance < config.amount_per_request { + return Err(FaucetError::InsufficientBalance); + } + + // Send tokens + let tx_hash = self.send_tokens(address, config.amount_per_request).await?; + + // Create request record + let request = FaucetRequest { + address: address.to_string(), + amount: config.amount_per_request, + timestamp, + tx_hash, + status: RequestStatus::Completed, + }; + + // Add to history with bounded size + let mut history = self.request_history.write(); + history.push(request.clone()); + if history.len() > MAX_HISTORY_SIZE { + history.remove(0); + } + + Ok(request) + } + + /// Get request history + pub fn get_history(&self, limit: usize) -> Vec { + let history = self.request_history.read(); + history.iter() + .rev() + .take(limit) + .cloned() + .collect() + } + + /// Get statistics + pub fn get_stats(&self) -> FaucetStats { + let history = self.request_history.read(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let hour_ago = now - 3600; + let day_ago = now - 86400; + + FaucetStats { + total_requests: history.len(), + requests_last_hour: history.iter().filter(|r| r.timestamp >= hour_ago).count(), + requests_last_day: history.iter().filter(|r| r.timestamp >= day_ago).count(), + total_distributed: history.iter().map(|r| r.amount).sum(), + } + } + + /// Validate address format + fn validate_address(&self, address: &str) -> Result<(), FaucetError> { + // Check if address starts with 0x and has correct length + if !address.starts_with("0x") || address.len() != 42 { + return Err(FaucetError::InvalidAddress( + "Address must start with 0x and be 42 characters".to_string() + )); + } + + // Check if all characters are hex + if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) { + return Err(FaucetError::InvalidAddress( + "Address contains invalid characters".to_string() + )); + } + + Ok(()) + } + + /// Get faucet address from private key + fn get_faucet_address(&self, private_key: &str) -> Result { + use bitcell_crypto::SecretKey; + + let key_bytes = hex::decode(private_key.trim_start_matches("0x")) + .map_err(|e| FaucetError::ConfigError(format!("Invalid private key: {}", e)))?; + + if key_bytes.len() != 32 { + return Err(FaucetError::ConfigError("Private key must be 32 bytes".to_string())); + } + + let mut arr = [0u8; 32]; + arr.copy_from_slice(&key_bytes); + + let secret_key = SecretKey::from_bytes(&arr) + .map_err(|e| FaucetError::ConfigError(format!("Invalid secret key: {}", e)))?; + + let public_key = secret_key.public_key(); + Ok(format!("0x{}", hex::encode(public_key.as_bytes()))) + } + + /// Send tokens to address + async fn send_tokens(&self, to_address: &str, amount: u64) -> Result { + use bitcell_crypto::SecretKey; + use bitcell_wallet::{Chain, Transaction as WalletTx}; + + let config = self.config.read().clone(); + let rpc_url = format!("http://{}:{}/rpc", config.node_rpc_host, config.node_rpc_port); + + // Parse private key + let key_bytes = hex::decode(config.private_key.trim_start_matches("0x")) + .map_err(|e| FaucetError::ConfigError(format!("Invalid private key: {}", e)))?; + + if key_bytes.len() != 32 { + return Err(FaucetError::ConfigError("Private key must be 32 bytes".to_string())); + } + + let mut arr = [0u8; 32]; + arr.copy_from_slice(&key_bytes); + + let secret_key = SecretKey::from_bytes(&arr) + .map_err(|e| FaucetError::ConfigError(format!("Invalid secret key: {}", e)))?; + + let from_address = self.get_faucet_address(&config.private_key)?; + + // Get nonce + let client = reqwest::Client::new(); + let nonce_req = serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_getTransactionCount", + "params": [from_address, "latest"], + "id": 1 + }); + + let resp = client + .post(&rpc_url) + .json(&nonce_req) + .send() + .await + .map_err(|e| FaucetError::TransactionFailed(e.to_string()))?; + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| FaucetError::TransactionFailed(e.to_string()))?; + + let nonce = if let Some(result) = json.get("result").and_then(|v| v.as_str()) { + u64::from_str_radix(result.trim_start_matches("0x"), 16) + .map_err(|e| FaucetError::TransactionFailed( + format!("Failed to parse nonce: {}", e) + ))? + } else { + return Err(FaucetError::TransactionFailed( + "Invalid RPC response for nonce: missing result field".to_string() + )); + }; + + // Build and sign transaction + let tx = WalletTx::new( + Chain::BitCell, + from_address.clone(), + to_address.to_string(), + amount, + STANDARD_TRANSFER_GAS, + nonce, + ); + + let signed_tx = tx.sign(&secret_key); + let tx_bytes = signed_tx.serialize() + .map_err(|e| FaucetError::TransactionFailed(format!("Serialization failed: {}", e)))?; + + let tx_hex = format!("0x{}", hex::encode(&tx_bytes)); + + // Broadcast transaction + let send_req = serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_sendRawTransaction", + "params": [tx_hex], + "id": 1 + }); + + let resp = client + .post(&rpc_url) + .json(&send_req) + .send() + .await + .map_err(|e| FaucetError::TransactionFailed(e.to_string()))?; + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| FaucetError::TransactionFailed(e.to_string()))?; + + if let Some(error) = json.get("error") { + return Err(FaucetError::TransactionFailed(error.to_string())); + } + + if let Some(result) = json.get("result").and_then(|v| v.as_str()) { + Ok(result.to_string()) + } else { + Ok(signed_tx.hash_hex()) + } + } +} + +/// Faucet statistics +#[derive(Debug, Clone, Serialize)] +pub struct FaucetStats { + pub total_requests: usize, + pub requests_last_hour: usize, + pub requests_last_day: usize, + pub total_distributed: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_config() -> FaucetConfig { + FaucetConfig { + private_key: "1234567890123456789012345678901234567890123456789012345678901234".to_string(), + require_captcha: false, + ..Default::default() + } + } + + #[test] + fn test_validate_address() { + let config = create_test_config(); + let service = FaucetService::new(config).expect("Failed to create service"); + + // Valid address + assert!(service.validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0").is_ok()); + + // Invalid: no 0x prefix + assert!(service.validate_address("742d35Cc6634C0532925a3b844Bc9e7595f0bEb0").is_err()); + + // Invalid: wrong length + assert!(service.validate_address("0x742d35Cc").is_err()); + + // Invalid: non-hex characters + assert!(service.validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEbZ").is_err()); + } + + #[test] + fn test_config_validation() { + // Valid config + let valid_config = create_test_config(); + assert!(FaucetService::new(valid_config).is_ok()); + + // Invalid: amount is 0 + let mut invalid = create_test_config(); + invalid.amount_per_request = 0; + assert!(FaucetService::new(invalid).is_err()); + + // Invalid: CAPTCHA enabled + let mut invalid = create_test_config(); + invalid.require_captcha = true; + assert!(FaucetService::new(invalid).is_err()); + + // Invalid: bad private key + let mut invalid = create_test_config(); + invalid.private_key = "short".to_string(); + assert!(FaucetService::new(invalid).is_err()); + } + + #[test] + fn test_rate_limiting() { + let mut config = create_test_config(); + config.rate_limit_seconds = 60; + config.max_requests_per_day = 3; + + let service = FaucetService::new(config).expect("Failed to create service"); + + let address = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0"; + + // First request should be allowed + assert!(service.check_rate_limit(address).is_ok()); + + // Atomically check and record + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(service.check_and_record_rate_limit(address, timestamp).is_ok()); + + // Second immediate request should be rate limited + assert!(matches!( + service.check_rate_limit(address), + Err(FaucetError::RateLimited(_)) + )); + } + + #[test] + fn test_daily_request_limit() { + let mut config = create_test_config(); + config.rate_limit_seconds = 1; // Very short for testing + config.max_requests_per_day = 2; + + let service = FaucetService::new(config).expect("Failed to create service"); + + let address = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0"; + let base_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Record 2 requests + assert!(service.check_and_record_rate_limit(address, base_time).is_ok()); + assert!(service.check_and_record_rate_limit(address, base_time + 2).is_ok()); + + // Third request should exceed daily limit + assert!(matches!( + service.check_rate_limit(address), + Err(FaucetError::RateLimited(_)) + )); + } + + #[test] + fn test_get_stats() { + let config = create_test_config(); + let service = FaucetService::new(config).expect("Failed to create service"); + + // Add some requests + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + service.request_history.write().push(FaucetRequest { + address: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0".to_string(), + amount: 1000, + timestamp: now, + tx_hash: "0xabc".to_string(), + status: RequestStatus::Completed, + }); + + let stats = service.get_stats(); + assert_eq!(stats.total_requests, 1); + assert_eq!(stats.total_distributed, 1000); + } + + #[test] + fn test_get_faucet_address() { + let config = create_test_config(); + let service = FaucetService::new(config).expect("Failed to create service"); + + // Test address derivation from private key + let result = service.get_faucet_address(&service.config.read().private_key); + assert!(result.is_ok()); + + let address = result.unwrap(); + // Address should start with 0x + assert!(address.starts_with("0x")); + // Address should be 42 characters (0x + 40 hex chars) + assert_eq!(address.len(), 42); + // Address should only contain hex characters after 0x + assert!(address[2..].chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/crates/bitcell-admin/src/lib.rs b/crates/bitcell-admin/src/lib.rs index f1f2b74..e66a245 100644 --- a/crates/bitcell-admin/src/lib.rs +++ b/crates/bitcell-admin/src/lib.rs @@ -7,6 +7,7 @@ //! - Testing utilities //! - Log aggregation and viewing //! - HSM integration for secure key management +//! - Testnet faucet service pub mod api; pub mod web; @@ -18,6 +19,7 @@ pub mod metrics_client; pub mod setup; pub mod system_metrics; pub mod hsm; +pub mod faucet; pub mod auth; pub mod audit; @@ -36,6 +38,7 @@ pub use deployment::DeploymentManager; pub use config::ConfigManager; pub use process::ProcessManager; pub use setup::SETUP_FILE_PATH; +pub use faucet::FaucetService; /// Administrative console server pub struct AdminConsole { @@ -47,6 +50,7 @@ pub struct AdminConsole { metrics_client: Arc, setup: Arc, system_metrics: Arc, + faucet: Option>, auth: Arc, audit: Arc, } @@ -85,11 +89,23 @@ impl AdminConsole { metrics_client: Arc::new(metrics_client::MetricsClient::new()), setup, system_metrics, + faucet: None, auth, audit, } } + /// Enable faucet with configuration + pub fn with_faucet(mut self, faucet_config: faucet::FaucetConfig) -> Result { + match FaucetService::new(faucet_config) { + Ok(service) => { + self.faucet = Some(Arc::new(service)); + Ok(self) + } + Err(e) => Err(format!("Failed to initialize faucet: {}", e)), + } + } + /// Get the process manager pub fn process_manager(&self) -> Arc { self.process.clone() @@ -107,7 +123,12 @@ impl AdminConsole { // Public routes (no authentication required) let public_routes = Router::new() .route("/api/auth/login", post(api::auth::login)) - .route("/api/auth/refresh", post(api::auth::refresh)); + .route("/api/auth/refresh", post(api::auth::refresh)) + // Faucet routes are intentionally public for testnet use + .route("/faucet", get(web::faucet::faucet_page)) + .route("/api/faucet/request", post(api::faucet::request_tokens)) + .route("/api/faucet/info", get(api::faucet::get_info)) + .route("/api/faucet/check", post(api::faucet::check_eligibility)); // Protected routes requiring authentication let protected_routes = Router::new() @@ -130,6 +151,9 @@ impl AdminConsole { .route("/api/blocks/:height", get(api::blocks::get_block)) .route("/api/blocks/:height/battles", get(api::blocks::get_block_battles)) .route("/api/audit/logs", get(api::auth::get_audit_logs)) + // Faucet history and stats require authentication (contain operational data) + .route("/api/faucet/history", get(api::faucet::get_history)) + .route("/api/faucet/stats", get(api::faucet::get_stats)) // Operator routes (can start/stop nodes, deploy) .route("/api/nodes/:id/start", post(api::nodes::start_node)) @@ -179,6 +203,7 @@ impl AdminConsole { metrics_client: self.metrics_client.clone(), setup: self.setup.clone(), system_metrics: self.system_metrics.clone(), + faucet: self.faucet.clone(), auth: self.auth.clone(), audit: self.audit.clone(), })) @@ -207,6 +232,7 @@ pub struct AppState { pub metrics_client: Arc, pub setup: Arc, pub system_metrics: Arc, + pub faucet: Option>, pub auth: Arc, pub audit: Arc, } diff --git a/crates/bitcell-admin/src/web/faucet.rs b/crates/bitcell-admin/src/web/faucet.rs new file mode 100644 index 0000000..21c235e --- /dev/null +++ b/crates/bitcell-admin/src/web/faucet.rs @@ -0,0 +1,450 @@ +//! Faucet web interface + +use axum::response::{Html, IntoResponse}; + +/// Faucet page +pub async fn faucet_page() -> impl IntoResponse { + let html = r#" + + + + + + BitCell Testnet Faucet + + + +
+ ← Back to Dashboard + +
+

🌊 BitCell Testnet Faucet

+

Get free testnet tokens for development and testing

+
+ +
+
+ Amount per request: + Loading... +
+
+ Rate limit: + Loading... +
+
+ Faucet balance: + Loading... +
+
+ Requests today: + Loading... +
+
+ +
+
+ +
+
+

Processing your request...

+
+ +
+
+ + +
+ + +
+
+ +
+

Recent Distributions

+
+

Loading...

+
+
+
+ + + + + "#; + + Html(html) +} diff --git a/crates/bitcell-admin/src/web/mod.rs b/crates/bitcell-admin/src/web/mod.rs index 4e24639..f4ece79 100644 --- a/crates/bitcell-admin/src/web/mod.rs +++ b/crates/bitcell-admin/src/web/mod.rs @@ -1,6 +1,7 @@ //! Web interface module pub mod dashboard; +pub mod faucet; use tera::Tera; use std::sync::OnceLock; diff --git a/docs/FAUCET.md b/docs/FAUCET.md new file mode 100644 index 0000000..cf24022 --- /dev/null +++ b/docs/FAUCET.md @@ -0,0 +1,329 @@ +# BitCell Testnet Faucet + +The BitCell testnet faucet provides automated token distribution for testing and development purposes. + +## Features + +- **Rate Limiting**: Configurable time-based and daily request limits per address +- **Anti-Abuse Protection**: + - Maximum recipient balance check + - Address validation + - Request tracking and audit logging + - CAPTCHA support (configurable) +- **Web UI**: User-friendly interface for requesting tokens +- **API Endpoints**: RESTful API for integration +- **Real-time Statistics**: Track usage and distribution + +## Configuration + +The faucet is configured through the `FaucetConfig` struct: + +```rust +use bitcell_admin::faucet::FaucetConfig; + +let config = FaucetConfig { + amount_per_request: 1_000_000_000, // 1 CELL (in smallest units) + rate_limit_seconds: 3600, // 1 hour between requests + max_requests_per_day: 5, // Maximum 5 requests per day per address + private_key: "0x...".to_string(), // Faucet wallet private key + node_rpc_host: "127.0.0.1".to_string(), + node_rpc_port: 8545, + require_captcha: true, // Enable CAPTCHA verification + max_recipient_balance: Some(10_000_000_000), // Max 10 CELL balance +}; +``` + +### Environment Variables + +You can also configure the faucet using environment variables: + +```bash +export FAUCET_AMOUNT=1000000000 +export FAUCET_RATE_LIMIT=3600 +export FAUCET_MAX_REQUESTS_PER_DAY=5 +export FAUCET_PRIVATE_KEY=0x... +export FAUCET_NODE_RPC_HOST=127.0.0.1 +export FAUCET_NODE_RPC_PORT=8545 +export FAUCET_REQUIRE_CAPTCHA=true +export FAUCET_MAX_RECIPIENT_BALANCE=10000000000 +``` + +## Usage + +### Enabling the Faucet + +Add the faucet to your admin console: + +```rust +use bitcell_admin::{AdminConsole, faucet::FaucetConfig}; + +let config = FaucetConfig { + // ... your configuration + private_key: std::env::var("FAUCET_PRIVATE_KEY") + .expect("FAUCET_PRIVATE_KEY must be set"), + ..Default::default() +}; + +let console = AdminConsole::new("127.0.0.1:8080".parse().unwrap()) + .with_faucet(config); + +console.serve().await?; +``` + +### Web Interface + +Once enabled, the faucet UI is available at: + +``` +http://localhost:8080/faucet +``` + +Users can: +1. Enter their BitCell address +2. Complete CAPTCHA (if enabled) +3. Request testnet tokens +4. View recent distributions + +### API Endpoints + +#### Request Tokens + +```bash +POST /api/faucet/request +Content-Type: application/json + +{ + "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + "captcha_response": "optional_captcha_token" +} +``` + +Response: +```json +{ + "success": true, + "message": "Successfully sent 1000000000 tokens to 0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + "tx_hash": "0xabc123...", + "amount": 1000000000 +} +``` + +#### Get Faucet Info + +```bash +GET /api/faucet/info +``` + +Response: +```json +{ + "balance": 50000000000, + "amount_per_request": 1000000000, + "rate_limit_seconds": 3600, + "max_requests_per_day": 5, + "require_captcha": true +} +``` + +#### Check Eligibility + +```bash +POST /api/faucet/check +Content-Type: application/json + +{ + "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0" +} +``` + +Response: +```json +{ + "eligible": false, + "message": "Rate limit active. Try again in 2400 seconds", + "retry_after_seconds": 2400 +} +``` + +#### Get Request History + +```bash +GET /api/faucet/history +``` + +Returns the 50 most recent faucet distributions. + +#### Get Statistics + +```bash +GET /api/faucet/stats +``` + +Response: +```json +{ + "total_requests": 1234, + "requests_last_hour": 12, + "requests_last_day": 89, + "total_distributed": 1234000000000 +} +``` + +## Rate Limiting + +The faucet implements two types of rate limiting: + +1. **Time-based**: Minimum time between requests from the same address +2. **Daily limit**: Maximum number of requests per 24-hour period + +Rate limits are tracked per address and reset automatically. + +## Security Considerations + +### Private Key Management + +**⚠️ IMPORTANT**: The faucet private key should be: +- Stored securely (environment variables, secrets manager) +- Never committed to version control +- Limited to testnet funds only +- Rotated periodically + +### CAPTCHA Integration + +⚠️ **CRITICAL**: CAPTCHA validation is **NOT IMPLEMENTED**. When `require_captcha` is enabled, the faucet will **reject all requests** to prevent a false sense of security. + +**Current Status**: The CAPTCHA feature is disabled by default and must remain `false` until actual verification is implemented. + +**Future Implementation**: To enable CAPTCHA protection, implement one of: + +1. **reCAPTCHA**: Integrate Google reCAPTCHA v2/v3 +2. **hCaptcha**: Alternative CAPTCHA provider +3. **Custom**: Implement your own challenge system + +Do not enable `require_captcha` in production without implementing actual verification. + +### Anti-Abuse + +Additional anti-abuse measures: +- Maximum recipient balance check prevents excessive accumulation +- Request history tracking enables abuse detection (bounded to 10,000 entries) +- Rate limit cleanup prevents unbounded memory growth +- IP-based rate limiting can be added at the reverse proxy level + +## Monitoring + +### Known Limitations + +⚠️ **Memory Management**: +- Request history is bounded to 10,000 entries with automatic rotation +- Rate limit data is automatically cleaned up after 30 days of inactivity +- Long-running deployments should still be monitored for memory usage + +### Audit Logging + +All faucet requests are logged with: +- Timestamp +- Recipient address +- Amount sent +- Transaction hash +- Status (completed/failed) + +### Metrics + +Track faucet health: +- Faucet balance (alert when low) +- Request rate (detect unusual patterns) +- Success/failure ratio +- Distribution by time period + +### Alerts + +Set up alerts for: +- Low faucet balance (< 10 CELL) +- High request rate (> 100/hour) +- Failed transaction ratio (> 5%) +- Repeated failures from same address + +## Testing + +Run faucet tests: + +```bash +cargo test -p bitcell-admin faucet +``` + +Tests cover: +- Address validation +- Rate limiting (time-based and daily) +- Request statistics +- Error handling + +## Troubleshooting + +### "Faucet not enabled" + +The faucet was not initialized. Ensure you call `.with_faucet(config)` when creating the AdminConsole. + +### "Rate limit exceeded" + +The address has made a request too recently. Wait for the cooldown period specified in the error message. + +### "Faucet balance too low" + +The faucet wallet needs to be refilled. Transfer testnet tokens to the faucet address. + +### "Transaction failed" + +Check: +- Node RPC connection +- Faucet private key is valid +- Faucet wallet has sufficient balance +- Gas fees are reasonable + +## Example Deployment + +```bash +# 1. Generate faucet wallet +# (use bitcell-wallet or any Ethereum-compatible wallet) + +# 2. Fund the faucet wallet with testnet tokens +# Transfer ~100 CELL to the faucet address + +# 3. Set environment variables +export FAUCET_PRIVATE_KEY="0x..." +export FAUCET_AMOUNT=1000000000 +export FAUCET_RATE_LIMIT=3600 + +# 4. Run admin console with faucet +cargo run -p bitcell-admin --release + +# 5. Access faucet UI +# Open http://localhost:8080/faucet in your browser +``` + +## Best Practices + +1. **Testnet Only**: Never use mainnet funds in a faucet +2. **Rate Limits**: Set conservative limits to prevent abuse +3. **Monitoring**: Track usage and set up alerts +4. **CAPTCHA**: Always enable for public deployments +5. **Balance**: Keep faucet well-funded but not excessive +6. **Rotation**: Rotate faucet wallet periodically +7. **Logs**: Retain request logs for abuse investigation +8. **Documentation**: Provide clear usage instructions to users + +## Future Enhancements + +Potential improvements: +- [ ] GitHub OAuth integration +- [ ] Discord bot integration +- [ ] SMS verification +- [ ] Progressive request amounts (smaller for new users) +- [ ] Reputation system (trusted users get higher limits) +- [ ] Multi-faucet support (different networks) +- [ ] Admin dashboard for faucet management +- [ ] Automated refilling from treasury + +## License + +MIT OR Apache-2.0 diff --git a/docs/RELEASE_REQUIREMENTS.md b/docs/RELEASE_REQUIREMENTS.md index 095daa1..2c2f20b 100644 --- a/docs/RELEASE_REQUIREMENTS.md +++ b/docs/RELEASE_REQUIREMENTS.md @@ -604,18 +604,44 @@ State Circuit: --- -### RC2-010: Testnet Faucet +### RC2-010: Testnet Faucet ✅ COMPLETE **Priority:** Medium **Estimated Effort:** 1 week -**Dependencies:** RC2-005 (RocksDB) +**Dependencies:** RC2-005 (RocksDB) +**Status:** Complete #### Requirements -| Requirement | Description | Acceptance Criteria | -|-------------|-------------|---------------------| -| **RC2-010.1** Faucet API | Token distribution endpoint | - Rate limiting per address
- CAPTCHA integration
- Amount limits | -| **RC2-010.2** Web Interface | User-friendly faucet UI | - Address input
- Transaction status
- Queue position | +| Requirement | Description | Acceptance Criteria | Status | +|-------------|-------------|---------------------|--------| +| **RC2-010.1** Faucet API | Token distribution endpoint | - Rate limiting per address
- CAPTCHA integration
- Amount limits | ✅ Complete | +| **RC2-010.2** Web Interface | User-friendly faucet UI | - Address input
- Transaction status
- Recent distributions | ✅ Complete | + +#### Implementation Details + +**Module:** `crates/bitcell-admin/src/faucet.rs`, `crates/bitcell-admin/src/api/faucet.rs` + +**Features Implemented:** +- Rate limiting: time-based and daily request limits per address +- Anti-abuse: maximum recipient balance check, address validation +- Request tracking and audit logging with full history +- CAPTCHA support (configurable, ready for integration) +- Comprehensive API endpoints (request, info, history, stats, check eligibility) +- Modern web UI with real-time updates +- Configurable via `FaucetConfig` + +**API Endpoints:** +- `POST /api/faucet/request` - Request tokens +- `GET /api/faucet/info` - Get faucet information +- `GET /api/faucet/history` - Get request history +- `GET /api/faucet/stats` - Get usage statistics +- `POST /api/faucet/check` - Check address eligibility +- `GET /faucet` - Web UI + +**Tests:** 4 unit tests covering validation, rate limiting, and statistics + +**Documentation:** See `docs/FAUCET.md` and `examples/faucet.env` --- diff --git a/examples/faucet.env b/examples/faucet.env new file mode 100644 index 0000000..dc0d230 --- /dev/null +++ b/examples/faucet.env @@ -0,0 +1,69 @@ +# BitCell Testnet Faucet Configuration Example +# +# This file shows environment variables that can be used to configure +# the testnet faucet service. These variables are read by examples/faucet_admin.rs. +# Copy this to .env in your deployment and adjust values as needed. + +# Amount to distribute per request (in smallest units) +# 1 CELL = 1_000_000_000 smallest units +# Default: 1_000_000_000 (1 CELL) +FAUCET_AMOUNT=1000000000 + +# Minimum time between requests from the same address (in seconds) +# Default: 3600 (1 hour) +FAUCET_RATE_LIMIT=3600 + +# Maximum number of requests per address per day +# Default: 5 +FAUCET_MAX_REQUESTS_PER_DAY=5 + +# Faucet wallet private key (hex format, with or without 0x prefix) +# ⚠️ SECURITY: Never commit this to version control! +# ⚠️ Use environment variables or secrets management in production +# Generate a new wallet for testnet use only +FAUCET_PRIVATE_KEY= + +# BitCell node RPC endpoint +FAUCET_NODE_RPC_HOST=127.0.0.1 +FAUCET_NODE_RPC_PORT=8545 + +# Enable CAPTCHA verification (true/false) +# ⚠️ WARNING: CAPTCHA validation is NOT implemented +# ⚠️ If enabled, the faucet will reject all requests +# ⚠️ Keep this disabled until proper CAPTCHA verification is added +# Recommended: false +# Default: false +FAUCET_REQUIRE_CAPTCHA=false + +# Maximum balance an address can have to receive funds (optional) +# Prevents addresses from accumulating too many testnet tokens +# Set to 0 or omit to disable this check +# Default: 10_000_000_000 (10 CELL) +FAUCET_MAX_RECIPIENT_BALANCE=10000000000 + +# Admin console bind address +ADMIN_CONSOLE_HOST=127.0.0.1 +ADMIN_CONSOLE_PORT=8080 + +# Logging level (trace, debug, info, warn, error) +RUST_LOG=info + +# Example deployment configurations: + +# --- Development (generous limits for testing) --- +# FAUCET_AMOUNT=5000000000 +# FAUCET_RATE_LIMIT=300 +# FAUCET_MAX_REQUESTS_PER_DAY=20 +# FAUCET_REQUIRE_CAPTCHA=false + +# --- Public Testnet (conservative limits) --- +# FAUCET_AMOUNT=1000000000 +# FAUCET_RATE_LIMIT=3600 +# FAUCET_MAX_REQUESTS_PER_DAY=5 +# FAUCET_REQUIRE_CAPTCHA=false # Keep disabled until CAPTCHA is implemented + +# --- Stress Testing (higher throughput) --- +# FAUCET_AMOUNT=10000000000 +# FAUCET_RATE_LIMIT=60 +# FAUCET_MAX_REQUESTS_PER_DAY=100 +# FAUCET_REQUIRE_CAPTCHA=false diff --git a/examples/faucet_admin.rs b/examples/faucet_admin.rs new file mode 100644 index 0000000..8130777 --- /dev/null +++ b/examples/faucet_admin.rs @@ -0,0 +1,112 @@ +// Example: Running BitCell Admin Console with Testnet Faucet +// +// This example shows how to set up and run the admin console +// with the testnet faucet enabled. + +use bitcell_admin::{AdminConsole, faucet::FaucetConfig}; +use std::net::SocketAddr; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter("info") + .init(); + + // Configure the faucet + // Load configuration from environment variables + let faucet_config = FaucetConfig { + // Amount per request (default: 1 CELL) + amount_per_request: std::env::var("FAUCET_AMOUNT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(1_000_000_000), + + // Rate limit in seconds (default: 1 hour) + rate_limit_seconds: std::env::var("FAUCET_RATE_LIMIT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(3600), + + // Max requests per day (default: 5) + max_requests_per_day: std::env::var("FAUCET_MAX_REQUESTS_PER_DAY") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(5), + + // Load private key from environment (NEVER hardcode!) + private_key: std::env::var("FAUCET_PRIVATE_KEY") + .expect("FAUCET_PRIVATE_KEY environment variable must be set"), + + // Node RPC endpoint + node_rpc_host: std::env::var("FAUCET_NODE_RPC_HOST") + .unwrap_or_else(|_| "127.0.0.1".to_string()), + node_rpc_port: std::env::var("FAUCET_NODE_RPC_PORT") + .unwrap_or_else(|_| "8545".to_string()) + .parse() + .expect("Invalid FAUCET_NODE_RPC_PORT"), + + // CAPTCHA verification (default: false - not implemented) + require_captcha: std::env::var("FAUCET_REQUIRE_CAPTCHA") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(false), + + // Maximum recipient balance (default: 10 CELL) + max_recipient_balance: std::env::var("FAUCET_MAX_RECIPIENT_BALANCE") + .ok() + .and_then(|s| s.parse().ok()) + .map(|v: u64| if v > 0 { Some(v) } else { None }) + .unwrap_or(Some(10_000_000_000)), + }; + + // Create admin console with faucet + let addr: SocketAddr = std::env::var("ADMIN_CONSOLE_ADDR") + .unwrap_or_else(|_| "127.0.0.1:8080".to_string()) + .parse() + .expect("Invalid ADMIN_CONSOLE_ADDR"); + + println!("Starting BitCell Admin Console with Faucet..."); + println!(" Address: http://{}", addr); + println!(" Faucet UI: http://{}/faucet", addr); + println!(" API Docs: http://{}/api/faucet/info", addr); + println!(); + println!("Faucet Configuration:"); + println!(" Amount per request: {} CELL", faucet_config.amount_per_request as f64 / 1e9); + println!(" Rate limit: {} seconds", faucet_config.rate_limit_seconds); + println!(" Max requests/day: {}", faucet_config.max_requests_per_day); + println!(" CAPTCHA required: {} (WARNING: not implemented - must be false)", faucet_config.require_captcha); + + let console = AdminConsole::new(addr) + .with_faucet(faucet_config) + .expect("Failed to initialize faucet"); + + // Start the server + console.serve().await +} + +// Example environment setup: +// +// ```bash +// # Generate a new testnet wallet +// # (In production, use bitcell-wallet or similar) +// export FAUCET_PRIVATE_KEY="1234567890123456789012345678901234567890123456789012345678901234" +// +// # Configure node RPC endpoint +// export FAUCET_NODE_RPC_HOST="127.0.0.1" +// export FAUCET_NODE_RPC_PORT="8545" +// +// # Optional: customize faucet amount and limits +// export FAUCET_AMOUNT="1000000000" # 1 CELL per request +// export FAUCET_RATE_LIMIT="3600" # 1 hour between requests +// export FAUCET_MAX_REQUESTS_PER_DAY="5" # 5 requests per day max +// export FAUCET_MAX_RECIPIENT_BALANCE="10000000000" # 10 CELL max balance +// +// # CAPTCHA (keep disabled - not implemented) +// export FAUCET_REQUIRE_CAPTCHA="false" +// +// # Run the admin console +// cargo run --example faucet_admin +// ``` +// +// Then visit http://localhost:8080/faucet in your browser.