synor/apps/faucet/src/main.rs
Gulshan Yadav 48949ebb3f Initial commit: Synor blockchain monorepo
A complete blockchain implementation featuring:
- synord: Full node with GHOSTDAG consensus
- explorer-web: Modern React blockchain explorer with 3D DAG visualization
- CLI wallet and tools
- Smart contract SDK and example contracts (DEX, NFT, token)
- WASM crypto library for browser/mobile
2026-01-08 05:22:17 +05:30

690 lines
21 KiB
Rust

//! 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<String>,
/// 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<HeaderValue> = 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<HashMap<String, RequestRecord>>,
/// HTTP client for RPC calls.
http_client: reqwest::Client,
/// Rate limiter by IP.
rate_limiter: RateLimiter<String, DashMapStateStore<String>, governor::clock::DefaultClock>,
/// Statistics.
stats: RwLock<FaucetStats>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
amount: Option<String>,
}
/// 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::<SocketAddr>(),
)
.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#"<!DOCTYPE html>
<html>
<head>
<title>Synor Testnet Faucet</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background: #1a1a2e;
color: #eee;
}
h1 { color: #00d4ff; }
.container {
background: #16213e;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
input {
width: 100%;
padding: 12px;
margin: 10px 0;
border: 1px solid #333;
border-radius: 5px;
background: #0f0f23;
color: #fff;
font-size: 14px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 12px;
background: #00d4ff;
color: #000;
border: none;
border-radius: 5px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: background 0.3s;
}
button:hover { background: #00a8cc; }
button:disabled { background: #666; cursor: not-allowed; }
.result {
margin-top: 20px;
padding: 15px;
border-radius: 5px;
display: none;
}
.success { background: #0a3622; border: 1px solid #00ff88; }
.error { background: #3a1616; border: 1px solid #ff4444; }
.info { margin-top: 20px; font-size: 14px; color: #888; }
code { background: #0f0f23; padding: 2px 6px; border-radius: 3px; }
.result-title { font-weight: bold; display: block; margin-bottom: 5px; }
.result-message { display: block; }
.result-tx { display: block; margin-top: 5px; font-family: monospace; word-break: break-all; }
</style>
</head>
<body>
<div class="container">
<h1>Synor Testnet Faucet</h1>
<p>Get free testnet SYNOR tokens for development and testing.</p>
<form id="faucetForm">
<input type="text" id="address" placeholder="Enter your Synor address (synor1...)" required>
<button type="submit" id="submitBtn">Request Tokens</button>
</form>
<div id="result" class="result">
<span id="resultTitle" class="result-title"></span>
<span id="resultMessage" class="result-message"></span>
<span id="resultTx" class="result-tx"></span>
</div>
<div class="info">
<p><strong>Rules:</strong></p>
<ul>
<li>10 SYNOR per request</li>
<li>1 hour cooldown between requests</li>
<li>Testnet tokens have no real value</li>
</ul>
<p><strong>API:</strong> <code>POST /faucet</code> with <code>{"address": "synor1..."}</code></p>
</div>
</div>
<script>
document.getElementById('faucetForm').addEventListener('submit', async (e) => {
e.preventDefault();
const address = document.getElementById('address').value;
const btn = document.getElementById('submitBtn');
const result = document.getElementById('result');
const resultTitle = document.getElementById('resultTitle');
const resultMessage = document.getElementById('resultMessage');
const resultTx = document.getElementById('resultTx');
btn.disabled = true;
btn.textContent = 'Requesting...';
try {
const response = await fetch('/faucet', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address })
});
const data = await response.json();
result.style.display = 'block';
// Clear previous content
resultTitle.textContent = '';
resultMessage.textContent = '';
resultTx.textContent = '';
if (data.success) {
result.className = 'result success';
resultTitle.textContent = 'Success!';
resultMessage.textContent = data.message;
if (data.tx_hash) {
resultTx.textContent = 'TX: ' + data.tx_hash;
}
} else {
result.className = 'result error';
resultTitle.textContent = 'Error:';
resultMessage.textContent = data.message;
}
} catch (err) {
result.style.display = 'block';
result.className = 'result error';
resultTitle.textContent = 'Error:';
resultMessage.textContent = err.message;
resultTx.textContent = '';
}
btn.disabled = false;
btn.textContent = 'Request Tokens';
});
</script>
</body>
</html>"#;
(StatusCode::OK, [("content-type", "text/html")], html)
}
/// Health check endpoint.
async fn health(State(state): State<Arc<FaucetState>>) -> 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<Arc<FaucetState>>) -> 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<Arc<FaucetState>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(request): Json<FaucetRequest>,
) -> 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<Option<String>> {
// 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<SendResult>,
error: Option<RpcError>,
}
#[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)
}
}