Skip to content

Commit cd590db

Browse files
CopilotSteake
andauthored
Implement testnet faucet service with rate limiting and web UI (#105)
* Initial plan * Add testnet faucet service with rate limiting and web UI - Added governor dependency for rate limiting - Created FaucetService with configurable rate limits and anti-abuse - Implemented faucet API endpoints (request, info, history, stats, check) - Added faucet web UI with responsive design - Integrated faucet into admin console with optional enablement - Added comprehensive unit tests for rate limiting and validation - All tests passing (14 total including 4 faucet tests) Co-authored-by: Steake <530040+Steake@users.noreply.github.com> * Add faucet documentation and examples - Created comprehensive FAUCET.md documentation - Added example configuration file (examples/faucet.env) - Added example usage code (examples/faucet_admin.rs) - Updated RELEASE_REQUIREMENTS.md to mark RC2-010 as complete - Documented all features, API endpoints, configuration, and best practices Co-authored-by: Steake <530040+Steake@users.noreply.github.com> * Address code review feedback for faucet service - Removed unused governor dependency - Enforced CAPTCHA check when required (prevent bypass) - Fixed balance parsing to return proper errors instead of silent failures - Fixed nonce retrieval to fail on error instead of defaulting to 0 - Fixed UTC day calculation (was using modulo incorrectly) - Removed unused Duration import - All tests still passing Co-authored-by: Steake <530040+Steake@users.noreply.github.com> * Fix critical security issues in faucet implementation - Fix XSS vulnerability: Use DOM manipulation instead of innerHTML - Remove CAPTCHA bypass: Disabled by default, returns error if enabled - Fix memory leaks: Add bounded history (10k) and rate limit cleanup (30 days) - Fix race conditions: Atomic check-and-set for rate limits - Add config validation: Validate all fields before service creation - Fix error handling: Return proper errors instead of silent failures - Add constant for gas limit: STANDARD_TRANSFER_GAS - Update documentation: Clear warnings about CAPTCHA limitations - Update tests: All use create_test_config() helper Security improvements address all blocking issues from code review. Co-authored-by: Steake <530040+Steake@users.noreply.github.com> * Address remaining code review suggestions - Add test for get_faucet_address method (test_get_faucet_address) - Fix documentation inconsistency: example now reads all env vars from faucet.env - Update example to use FAUCET_* prefixed environment variables - Fix CAPTCHA default in example from true to false - Update environment variable names to match faucet.env (FAUCET_NODE_RPC_HOST, etc.) - Add clear comments explaining env var usage in faucet.env - Update example documentation with correct environment setup All remaining actionable code review items have been addressed. Co-authored-by: Steake <530040+Steake@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Steake <530040+Steake@users.noreply.github.com>
1 parent a0edeb7 commit cd590db

10 files changed

Lines changed: 1959 additions & 7 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
//! Faucet API endpoints
2+
3+
use axum::{
4+
extract::{State, Json},
5+
response::IntoResponse,
6+
http::StatusCode,
7+
};
8+
use serde::{Deserialize, Serialize};
9+
use std::sync::Arc;
10+
use crate::{AppState, faucet::{FaucetError, FaucetRequest as ServiceRequest}};
11+
12+
/// Faucet request
13+
#[derive(Debug, Deserialize)]
14+
pub struct FaucetRequest {
15+
/// Recipient address
16+
pub address: String,
17+
/// CAPTCHA response token
18+
pub captcha_response: Option<String>,
19+
}
20+
21+
/// Faucet response
22+
#[derive(Debug, Serialize)]
23+
pub struct FaucetResponse {
24+
pub success: bool,
25+
pub message: String,
26+
pub tx_hash: Option<String>,
27+
pub amount: Option<u64>,
28+
}
29+
30+
/// Faucet info response
31+
#[derive(Debug, Serialize)]
32+
pub struct FaucetInfoResponse {
33+
pub balance: u64,
34+
pub amount_per_request: u64,
35+
pub rate_limit_seconds: u64,
36+
pub max_requests_per_day: usize,
37+
pub require_captcha: bool,
38+
}
39+
40+
/// Request testnet tokens
41+
pub async fn request_tokens(
42+
State(state): State<Arc<AppState>>,
43+
Json(req): Json<FaucetRequest>,
44+
) -> impl IntoResponse {
45+
let faucet = match &state.faucet {
46+
Some(f) => f,
47+
None => return (
48+
StatusCode::NOT_FOUND,
49+
Json(FaucetResponse {
50+
success: false,
51+
message: "Faucet not enabled".to_string(),
52+
tx_hash: None,
53+
amount: None,
54+
})
55+
).into_response(),
56+
};
57+
58+
match faucet.process_request(
59+
&req.address,
60+
req.captcha_response.as_deref(),
61+
).await {
62+
Ok(request) => {
63+
Json(FaucetResponse {
64+
success: true,
65+
message: format!(
66+
"Successfully sent {} tokens to {}",
67+
request.amount, request.address
68+
),
69+
tx_hash: Some(request.tx_hash),
70+
amount: Some(request.amount),
71+
}).into_response()
72+
}
73+
Err(e) => {
74+
let (status, message) = match e {
75+
FaucetError::RateLimited(seconds) => (
76+
StatusCode::TOO_MANY_REQUESTS,
77+
format!("Rate limit exceeded. Try again in {} seconds", seconds)
78+
),
79+
FaucetError::InvalidAddress(msg) => (
80+
StatusCode::BAD_REQUEST,
81+
msg
82+
),
83+
FaucetError::InvalidCaptcha => (
84+
StatusCode::BAD_REQUEST,
85+
"Invalid CAPTCHA response".to_string()
86+
),
87+
FaucetError::InsufficientBalance => (
88+
StatusCode::SERVICE_UNAVAILABLE,
89+
"Faucet balance too low. Please contact administrator.".to_string()
90+
),
91+
_ => (
92+
StatusCode::INTERNAL_SERVER_ERROR,
93+
format!("Failed to process request: {}", e)
94+
),
95+
};
96+
97+
(
98+
status,
99+
Json(FaucetResponse {
100+
success: false,
101+
message,
102+
tx_hash: None,
103+
amount: None,
104+
})
105+
).into_response()
106+
}
107+
}
108+
}
109+
110+
/// Get faucet information
111+
pub async fn get_info(
112+
State(state): State<Arc<AppState>>,
113+
) -> impl IntoResponse {
114+
let faucet = match &state.faucet {
115+
Some(f) => f,
116+
None => return (StatusCode::NOT_FOUND, "Faucet not enabled").into_response(),
117+
};
118+
119+
let config = faucet.get_config();
120+
121+
let balance = match faucet.get_balance().await {
122+
Ok(b) => b,
123+
Err(e) => return (
124+
StatusCode::SERVICE_UNAVAILABLE,
125+
format!("Failed to fetch faucet balance: {}", e)
126+
).into_response(),
127+
};
128+
129+
Json(FaucetInfoResponse {
130+
balance,
131+
amount_per_request: config.amount_per_request,
132+
rate_limit_seconds: config.rate_limit_seconds,
133+
max_requests_per_day: config.max_requests_per_day,
134+
require_captcha: config.require_captcha,
135+
}).into_response()
136+
}
137+
138+
/// Get recent faucet requests
139+
pub async fn get_history(
140+
State(state): State<Arc<AppState>>,
141+
) -> impl IntoResponse {
142+
let faucet = match &state.faucet {
143+
Some(f) => f,
144+
None => return (StatusCode::NOT_FOUND, "Faucet not enabled").into_response(),
145+
};
146+
147+
let history = faucet.get_history(50);
148+
Json(history).into_response()
149+
}
150+
151+
/// Get faucet statistics
152+
pub async fn get_stats(
153+
State(state): State<Arc<AppState>>,
154+
) -> impl IntoResponse {
155+
let faucet = match &state.faucet {
156+
Some(f) => f,
157+
None => return (StatusCode::NOT_FOUND, "Faucet not enabled").into_response(),
158+
};
159+
160+
let stats = faucet.get_stats();
161+
Json(stats).into_response()
162+
}
163+
164+
/// Check if address can request tokens
165+
#[derive(Debug, Deserialize)]
166+
pub struct CheckEligibilityRequest {
167+
pub address: String,
168+
}
169+
170+
#[derive(Debug, Serialize)]
171+
pub struct CheckEligibilityResponse {
172+
pub eligible: bool,
173+
pub message: String,
174+
pub retry_after_seconds: Option<u64>,
175+
}
176+
177+
pub async fn check_eligibility(
178+
State(state): State<Arc<AppState>>,
179+
Json(req): Json<CheckEligibilityRequest>,
180+
) -> impl IntoResponse {
181+
let faucet = match &state.faucet {
182+
Some(f) => f,
183+
None => return (StatusCode::NOT_FOUND, "Faucet not enabled").into_response(),
184+
};
185+
186+
match faucet.check_rate_limit(&req.address) {
187+
Ok(_) => Json(CheckEligibilityResponse {
188+
eligible: true,
189+
message: "Address is eligible for faucet request".to_string(),
190+
retry_after_seconds: None,
191+
}).into_response(),
192+
Err(FaucetError::RateLimited(seconds)) => Json(CheckEligibilityResponse {
193+
eligible: false,
194+
message: format!("Rate limit active. Try again in {} seconds", seconds),
195+
retry_after_seconds: Some(seconds),
196+
}).into_response(),
197+
Err(e) => Json(CheckEligibilityResponse {
198+
eligible: false,
199+
message: e.to_string(),
200+
retry_after_seconds: None,
201+
}).into_response(),
202+
}
203+
}

crates/bitcell-admin/src/api/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod test;
88
pub mod setup;
99
pub mod blocks;
1010
pub mod wallet;
11+
pub mod faucet;
1112
pub mod auth;
1213

1314
use std::collections::HashMap;

0 commit comments

Comments
 (0)