diff --git a/.env.example b/.env.example index a29284a..f382b9f 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,9 @@ RPC_BATCH_SIZE=20 # API_PORT=3000 # API_DB_MAX_CONNECTIONS=20 # SSE_REPLAY_BUFFER_BLOCKS=4096 # replay tail used only for active connected clients + +# Optional faucet feature +# FAUCET_ENABLED=false +# FAUCET_PRIVATE_KEY=0x... +# FAUCET_AMOUNT=0.01 +# FAUCET_COOLDOWN_MINUTES=30 diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 97da7bb..4c5b1e9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -17,6 +17,7 @@ tokio = { version = "1.43", features = ["full"] } # Web framework axum = { version = "0.8", features = ["macros"] } +tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace", "timeout"] } # Database diff --git a/backend/crates/atlas-common/src/error.rs b/backend/crates/atlas-common/src/error.rs index edc50a1..d118fdb 100644 --- a/backend/crates/atlas-common/src/error.rs +++ b/backend/crates/atlas-common/src/error.rs @@ -37,6 +37,12 @@ pub enum AtlasError { #[error("Bytecode mismatch: {0}")] BytecodeMismatch(String), + + #[error("Too many requests: {message} (retry after {retry_after_seconds}s)")] + TooManyRequests { + message: String, + retry_after_seconds: u64, + }, } impl AtlasError { @@ -50,6 +56,7 @@ impl AtlasError { AtlasError::Config(_) => 500, AtlasError::Verification(_) | AtlasError::BytecodeMismatch(_) => 400, AtlasError::Compilation(_) => 422, + AtlasError::TooManyRequests { .. } => 429, } } } diff --git a/backend/crates/atlas-server/Cargo.toml b/backend/crates/atlas-server/Cargo.toml index 858b78f..d89113e 100644 --- a/backend/crates/atlas-server/Cargo.toml +++ b/backend/crates/atlas-server/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" atlas-common = { workspace = true } tokio = { workspace = true } axum = { workspace = true } +tower = { workspace = true } tower-http = { workspace = true } sqlx = { workspace = true } alloy = { workspace = true } diff --git a/backend/crates/atlas-server/src/api/error.rs b/backend/crates/atlas-server/src/api/error.rs index 45c83a7..ef93aa0 100644 --- a/backend/crates/atlas-server/src/api/error.rs +++ b/backend/crates/atlas-server/src/api/error.rs @@ -1,5 +1,5 @@ use axum::{ - http::StatusCode, + http::{header::RETRY_AFTER, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Json, }; @@ -63,6 +63,7 @@ impl IntoResponse for ApiError { AtlasError::Verification(msg) => msg.clone(), AtlasError::BytecodeMismatch(msg) => msg.clone(), AtlasError::Compilation(msg) => msg.clone(), + AtlasError::TooManyRequests { message, .. } => message.clone(), // Opaque: log full detail, return generic message AtlasError::Database(inner) => { tracing::error!(error = %inner, "Database error"); @@ -86,9 +87,52 @@ impl IntoResponse for ApiError { } }; - let body = Json(json!({ "error": client_message })); - (status, body).into_response() + let body = match &self.0 { + AtlasError::TooManyRequests { + retry_after_seconds, + .. + } => Json(json!({ + "error": client_message, + "retry_after_seconds": retry_after_seconds, + })), + _ => Json(json!({ "error": client_message })), + }; + + let mut response = (status, body).into_response(); + if let AtlasError::TooManyRequests { + retry_after_seconds, + .. + } = &self.0 + { + if let Ok(header_value) = HeaderValue::from_str(&retry_after_seconds.to_string()) { + response.headers_mut().insert(RETRY_AFTER, header_value); + } + } + response } } pub type ApiResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + + #[tokio::test] + async fn too_many_requests_sets_retry_after_header_and_body() { + let response = ApiError(AtlasError::TooManyRequests { + message: "Faucet cooldown active".to_string(), + retry_after_seconds: 42, + }) + .into_response(); + + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(response.headers().get(RETRY_AFTER).unwrap(), "42"); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["error"], "Faucet cooldown active"); + assert_eq!(value["retry_after_seconds"], 42); + } +} diff --git a/backend/crates/atlas-server/src/api/handlers/faucet.rs b/backend/crates/atlas-server/src/api/handlers/faucet.rs new file mode 100644 index 0000000..80efb5c --- /dev/null +++ b/backend/crates/atlas-server/src/api/handlers/faucet.rs @@ -0,0 +1,332 @@ +use alloy::primitives::Address; +use axum::{extract::State, http::HeaderMap, Json}; +use serde::Deserialize; +use std::{net::IpAddr, str::FromStr, sync::Arc}; + +use atlas_common::AtlasError; + +use crate::api::error::ApiResult; +use crate::api::AppState; + +#[derive(Debug, Deserialize)] +pub struct FaucetRequest { + pub address: String, +} + +pub async fn get_faucet_info( + State(state): State>, +) -> ApiResult> { + let faucet = state + .faucet + .as_ref() + .ok_or_else(|| AtlasError::NotFound("Faucet is disabled".to_string()))?; + + Ok(Json(faucet.info().await?)) +} + +pub async fn request_faucet( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> ApiResult> { + let faucet = state + .faucet + .as_ref() + .ok_or_else(|| AtlasError::NotFound("Faucet is disabled".to_string()))?; + + let recipient: Address = request + .address + .parse() + .map_err(|_| AtlasError::InvalidInput("Invalid faucet address".to_string()))?; + let client_ip = extract_client_ip(&headers)?; + + Ok(Json(faucet.request_faucet(recipient, client_ip).await?)) +} + +fn extract_client_ip(headers: &HeaderMap) -> Result { + // Prefer X-Real-IP — set by nginx to $remote_addr (trustworthy, not spoofable) + if let Some(value) = headers.get("x-real-ip") { + let real_ip = value + .to_str() + .map_err(|_| AtlasError::InvalidInput("Invalid X-Real-IP header".to_string()))?; + if !real_ip.trim().is_empty() { + return normalize_ip(real_ip.trim()); + } + } + + // Fallback: rightmost X-Forwarded-For entry (the one nginx appended). + // The leftmost entry is attacker-controlled when nginx uses $proxy_add_x_forwarded_for. + if let Some(value) = headers.get("x-forwarded-for") { + let forwarded = value + .to_str() + .map_err(|_| AtlasError::InvalidInput("Invalid X-Forwarded-For header".to_string()))?; + if let Some(ip) = forwarded + .rsplit(',') + .next() + .map(str::trim) + .filter(|ip| !ip.is_empty()) + { + return normalize_ip(ip); + } + } + + Err(AtlasError::InvalidInput( + "Client IP is required for faucet requests".to_string(), + )) +} + +fn normalize_ip(ip: &str) -> Result { + let parsed = IpAddr::from_str(ip) + .map_err(|_| AtlasError::InvalidInput("Invalid client IP address".to_string()))?; + Ok(parsed.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::AppState; + use crate::faucet::{FaucetBackend, FaucetInfo, FaucetTxResponse, SharedFaucetBackend}; + use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use futures::future::{BoxFuture, FutureExt}; + use tokio::sync::broadcast; + use tower::util::ServiceExt; + + #[derive(Clone)] + struct FakeFaucet; + + #[derive(Clone)] + struct CoolingDownFaucet; + + impl FaucetBackend for FakeFaucet { + fn info(&self) -> BoxFuture<'static, Result> { + async move { + Ok(FaucetInfo { + amount_wei: "1000".to_string(), + balance_wei: "2000".to_string(), + cooldown_minutes: 30, + }) + } + .boxed() + } + + fn request_faucet( + &self, + _recipient: Address, + _client_ip: String, + ) -> BoxFuture<'static, Result> { + async move { + Ok(FaucetTxResponse { + tx_hash: "0xdeadbeef".to_string(), + }) + } + .boxed() + } + } + + impl FaucetBackend for CoolingDownFaucet { + fn info(&self) -> BoxFuture<'static, Result> { + async move { + Ok(FaucetInfo { + amount_wei: "1000".to_string(), + balance_wei: "2000".to_string(), + cooldown_minutes: 30, + }) + } + .boxed() + } + + fn request_faucet( + &self, + _recipient: Address, + _client_ip: String, + ) -> BoxFuture<'static, Result> { + async move { + Err(atlas_common::AtlasError::TooManyRequests { + message: "Faucet cooldown active".to_string(), + retry_after_seconds: 30, + }) + } + .boxed() + } + } + + fn test_state(faucet: Option) -> Arc { + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://test@localhost:5432/test") + .expect("lazy pool"); + let head_tracker = Arc::new(crate::head::HeadTracker::empty(10)); + let (tx, _) = broadcast::channel(1); + Arc::new(AppState { + pool, + block_events_tx: tx, + head_tracker, + rpc_url: String::new(), + faucet, + chain_id: 1, + chain_name: "Test Chain".to_string(), + }) + } + + #[tokio::test] + async fn faucet_info_route_is_available_when_enabled() { + let faucet: SharedFaucetBackend = Arc::new(FakeFaucet); + let app = Router::new() + .route("/api/faucet/info", get(get_faucet_info)) + .with_state(test_state(Some(faucet))); + + let response = app + .oneshot( + Request::builder() + .uri("/api/faucet/info") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["amount_wei"], "1000"); + assert_eq!(value["balance_wei"], "2000"); + assert_eq!(value["cooldown_minutes"], 30); + } + + #[tokio::test] + async fn faucet_post_route_returns_tx_hash() { + let faucet: SharedFaucetBackend = Arc::new(FakeFaucet); + let app = Router::new() + .route("/api/faucet", axum::routing::post(request_faucet)) + .with_state(test_state(Some(faucet))); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/faucet") + .header("content-type", "application/json") + .header("x-forwarded-for", "127.0.0.1") + .body(Body::from( + r#"{"address":"0x0000000000000000000000000000000000000001"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["tx_hash"], "0xdeadbeef"); + } + + #[tokio::test] + async fn faucet_info_route_is_404_when_disabled() { + let app = Router::new() + .route("/api/faucet/info", get(get_faucet_info)) + .with_state(test_state(None)); + + let response = app + .oneshot( + Request::builder() + .uri("/api/faucet/info") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn request_faucet_requires_client_ip() { + let faucet: SharedFaucetBackend = Arc::new(FakeFaucet); + let app = Router::new() + .route("/api/faucet", axum::routing::post(request_faucet)) + .with_state(test_state(Some(faucet))); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/faucet") + .header("content-type", "application/json") + .body(Body::from( + r#"{"address":"0x0000000000000000000000000000000000000001"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn request_faucet_returns_retry_after_when_cooling_down() { + let faucet: SharedFaucetBackend = Arc::new(CoolingDownFaucet); + let app = Router::new() + .route("/api/faucet", axum::routing::post(request_faucet)) + .with_state(test_state(Some(faucet))); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/faucet") + .header("content-type", "application/json") + .header("x-forwarded-for", "127.0.0.1") + .body(Body::from( + r#"{"address":"0x0000000000000000000000000000000000000001"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(response.headers().get("retry-after").unwrap(), "30"); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["error"], "Faucet cooldown active"); + assert_eq!(value["retry_after_seconds"], 30); + } + + #[test] + fn extract_client_ip_prefers_x_real_ip() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", "10.0.0.1".parse().unwrap()); + headers.insert("x-forwarded-for", "203.0.113.10, 10.0.0.1".parse().unwrap()); + + let ip = extract_client_ip(&headers).unwrap(); + assert_eq!(ip, "10.0.0.1"); + } + + #[test] + fn extract_client_ip_uses_rightmost_xff_when_no_real_ip() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + "203.0.113.10, 127.0.0.1".parse().unwrap(), + ); + + let ip = extract_client_ip(&headers).unwrap(); + assert_eq!(ip, "127.0.0.1"); + } + + #[test] + fn extract_client_ip_parses_ipv6() { + let mut headers = HeaderMap::new(); + headers.insert("x-real-ip", "::1".parse().unwrap()); + + let ip = extract_client_ip(&headers).unwrap(); + assert_eq!(ip, "::1"); + } +} diff --git a/backend/crates/atlas-server/src/api/handlers/mod.rs b/backend/crates/atlas-server/src/api/handlers/mod.rs index 61c1eb7..09e09b0 100644 --- a/backend/crates/atlas-server/src/api/handlers/mod.rs +++ b/backend/crates/atlas-server/src/api/handlers/mod.rs @@ -1,6 +1,7 @@ pub mod addresses; pub mod blocks; pub mod etherscan; +pub mod faucet; pub mod logs; pub mod nfts; pub mod proxy; diff --git a/backend/crates/atlas-server/src/api/handlers/status.rs b/backend/crates/atlas-server/src/api/handlers/status.rs index 539a347..792e3e2 100644 --- a/backend/crates/atlas-server/src/api/handlers/status.rs +++ b/backend/crates/atlas-server/src/api/handlers/status.rs @@ -98,6 +98,7 @@ mod tests { block_events_tx: tx, head_tracker, rpc_url: String::new(), + faucet: None, chain_id: 1, chain_name: "Test Chain".to_string(), })) diff --git a/backend/crates/atlas-server/src/api/mod.rs b/backend/crates/atlas-server/src/api/mod.rs index b931e08..81950c4 100644 --- a/backend/crates/atlas-server/src/api/mod.rs +++ b/backend/crates/atlas-server/src/api/mod.rs @@ -10,6 +10,7 @@ use tower_http::cors::{AllowOrigin, Any, CorsLayer}; use tower_http::timeout::TimeoutLayer; use tower_http::trace::TraceLayer; +use crate::faucet::SharedFaucetBackend; use crate::head::HeadTracker; pub struct AppState { @@ -17,6 +18,7 @@ pub struct AppState { pub block_events_tx: broadcast::Sender<()>, pub head_tracker: Arc, pub rpc_url: String, + pub faucet: Option, pub chain_id: u64, pub chain_name: String, } @@ -31,7 +33,7 @@ pub fn build_router(state: Arc, cors_origin: Option) -> Router .route("/api/events", get(handlers::sse::block_events)) .with_state(state.clone()); - Router::new() + let mut router = Router::new() // Blocks .route("/api/blocks", get(handlers::blocks::list_blocks)) .route("/api/blocks/{number}", get(handlers::blocks::get_block)) @@ -144,17 +146,28 @@ pub fn build_router(state: Arc, cors_origin: Option) -> Router .route("/api/height", get(handlers::status::get_height)) .route("/api/status", get(handlers::status::get_status)) // Health - .route("/health", get(|| async { "OK" })) + .route("/health", get(|| async { "OK" })); + + if state.faucet.is_some() { + router = router + .route("/api/faucet/info", get(handlers::faucet::get_faucet_info)) + .route( + "/api/faucet", + axum::routing::post(handlers::faucet::request_faucet), + ); + } + + router .layer(TimeoutLayer::with_status_code( axum::http::StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10), )) - .with_state(state) // Merge SSE routes (no TimeoutLayer so connections stay alive) .merge(sse_routes) // Shared layers applied to all routes .layer(build_cors_layer(cors_origin)) .layer(TraceLayer::new_for_http()) + .with_state(state) } /// Construct the CORS layer. /// @@ -175,3 +188,105 @@ fn build_cors_layer(cors_origin: Option) -> CorsLayer { .allow_methods(Any) .allow_headers(Any) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::faucet::{FaucetBackend, FaucetInfo, FaucetTxResponse, SharedFaucetBackend}; + use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + }; + use futures::future::{BoxFuture, FutureExt}; + use tokio::sync::broadcast; + use tower::util::ServiceExt; + + #[derive(Clone)] + struct FakeFaucet; + + impl FaucetBackend for FakeFaucet { + fn info(&self) -> BoxFuture<'static, Result> { + async move { + Ok(FaucetInfo { + amount_wei: "1000".to_string(), + balance_wei: "2000".to_string(), + cooldown_minutes: 30, + }) + } + .boxed() + } + + fn request_faucet( + &self, + _recipient: alloy::primitives::Address, + _client_ip: String, + ) -> BoxFuture<'static, Result> { + async move { + Ok(FaucetTxResponse { + tx_hash: "0xdeadbeef".to_string(), + }) + } + .boxed() + } + } + + fn test_state(faucet: Option) -> Arc { + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://test@localhost:5432/test") + .expect("lazy pool"); + let head_tracker = Arc::new(crate::head::HeadTracker::empty(10)); + let (tx, _) = broadcast::channel(1); + Arc::new(AppState { + pool, + block_events_tx: tx, + head_tracker, + rpc_url: String::new(), + faucet, + chain_id: 1, + chain_name: "Test Chain".to_string(), + }) + } + + #[tokio::test] + async fn faucet_routes_are_not_mounted_when_disabled() { + let app = build_router(test_state(None), None); + + let response = app + .oneshot( + Request::builder() + .uri("/api/faucet/info") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn faucet_routes_work_when_enabled() { + let faucet: SharedFaucetBackend = Arc::new(FakeFaucet); + let app = build_router(test_state(Some(faucet)), None); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/faucet") + .header("content-type", "application/json") + .header("x-forwarded-for", "127.0.0.1") + .body(Body::from( + r#"{"address":"0x0000000000000000000000000000000000000001"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["tx_hash"], "0xdeadbeef"); + } +} diff --git a/backend/crates/atlas-server/src/config.rs b/backend/crates/atlas-server/src/config.rs index f30ec4d..ab9d691 100644 --- a/backend/crates/atlas-server/src/config.rs +++ b/backend/crates/atlas-server/src/config.rs @@ -1,5 +1,7 @@ +use alloy::primitives::U256; +use alloy::signers::local::PrivateKeySigner; use anyhow::{bail, Context, Result}; -use std::env; +use std::{env, str::FromStr}; #[derive(Debug, Clone)] pub struct Config { @@ -34,6 +36,28 @@ pub struct Config { pub chain_name: String, } +#[derive(Clone)] +pub struct FaucetConfig { + pub enabled: bool, + pub private_key: Option, + pub amount_wei: Option, + pub cooldown_minutes: Option, +} + +impl std::fmt::Debug for FaucetConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FaucetConfig") + .field("enabled", &self.enabled) + .field( + "private_key", + &self.private_key.as_ref().map(|_| "[redacted]"), + ) + .field("amount_wei", &self.amount_wei) + .field("cooldown_minutes", &self.cooldown_minutes) + .finish() + } +} + impl Config { pub fn from_env() -> Result { let sse_replay_buffer_blocks: usize = env::var("SSE_REPLAY_BUFFER_BLOCKS") @@ -104,6 +128,96 @@ impl Config { } } +impl FaucetConfig { + pub fn from_env() -> Result { + let enabled = env::var("FAUCET_ENABLED") + .unwrap_or_else(|_| "false".to_string()) + .parse::() + .context("Invalid FAUCET_ENABLED")?; + + if !enabled { + return Ok(Self { + enabled, + private_key: None, + amount_wei: None, + cooldown_minutes: None, + }); + } + + let private_key = env::var("FAUCET_PRIVATE_KEY") + .context("FAUCET_PRIVATE_KEY must be set when FAUCET_ENABLED=true")?; + PrivateKeySigner::from_str(&private_key).context("Invalid FAUCET_PRIVATE_KEY")?; + + let amount = env::var("FAUCET_AMOUNT") + .context("FAUCET_AMOUNT must be set when FAUCET_ENABLED=true")?; + let amount_wei = parse_faucet_amount_to_wei(&amount)?; + if amount_wei == U256::ZERO { + bail!("FAUCET_AMOUNT must be greater than 0"); + } + + let cooldown_minutes = env::var("FAUCET_COOLDOWN_MINUTES") + .context("FAUCET_COOLDOWN_MINUTES must be set when FAUCET_ENABLED=true")? + .parse::() + .context("Invalid FAUCET_COOLDOWN_MINUTES")?; + if cooldown_minutes == 0 { + bail!("FAUCET_COOLDOWN_MINUTES must be greater than 0"); + } + if cooldown_minutes.checked_mul(60).is_none() { + bail!("FAUCET_COOLDOWN_MINUTES is too large"); + } + + Ok(Self { + enabled, + private_key: Some(private_key), + amount_wei: Some(amount_wei), + cooldown_minutes: Some(cooldown_minutes), + }) + } +} + +fn parse_faucet_amount_to_wei(amount: &str) -> Result { + let trimmed = amount.trim(); + if trimmed.is_empty() { + bail!("FAUCET_AMOUNT must not be empty"); + } + if trimmed.starts_with('-') { + bail!("FAUCET_AMOUNT must be positive"); + } + + let (whole, fractional) = match trimmed.split_once('.') { + Some((whole, fractional)) => (whole, fractional), + None => (trimmed, ""), + }; + + if whole.is_empty() && fractional.is_empty() { + bail!("FAUCET_AMOUNT must contain digits"); + } + if !whole.chars().all(|c| c.is_ascii_digit()) || !fractional.chars().all(|c| c.is_ascii_digit()) + { + bail!("FAUCET_AMOUNT must be a decimal ETH value"); + } + if fractional.len() > 18 { + bail!("FAUCET_AMOUNT supports at most 18 decimal places"); + } + + let wei_per_eth = U256::from(1_000_000_000_000_000_000u128); + let whole_wei = if whole.is_empty() { + U256::ZERO + } else { + U256::from_str_radix(whole, 10).context("Invalid FAUCET_AMOUNT")? + }; + + let fractional_wei = if fractional.is_empty() { + U256::ZERO + } else { + let mut padded = fractional.to_string(); + padded.extend(std::iter::repeat_n('0', 18 - fractional.len())); + U256::from_str_radix(&padded, 10).context("Invalid FAUCET_AMOUNT")? + }; + + Ok(whole_wei * wei_per_eth + fractional_wei) +} + #[cfg(test)] mod tests { use super::*; @@ -116,6 +230,16 @@ mod tests { env::set_var("RPC_URL", "http://localhost:8545"); } + fn set_valid_faucet_env() { + env::set_var("FAUCET_ENABLED", "true"); + env::set_var( + "FAUCET_PRIVATE_KEY", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ); + env::set_var("FAUCET_AMOUNT", "1.5"); + env::set_var("FAUCET_COOLDOWN_MINUTES", "30"); + } + #[test] fn sse_replay_buffer_validation() { let _lock = ENV_LOCK.lock().unwrap(); @@ -148,4 +272,70 @@ mod tests { env::remove_var("SSE_REPLAY_BUFFER_BLOCKS"); } + + #[test] + fn faucet_config_defaults_disabled() { + let _lock = ENV_LOCK.lock().unwrap(); + env::remove_var("FAUCET_ENABLED"); + env::remove_var("FAUCET_PRIVATE_KEY"); + env::remove_var("FAUCET_AMOUNT"); + env::remove_var("FAUCET_COOLDOWN_MINUTES"); + + let faucet = FaucetConfig::from_env().unwrap(); + assert!(!faucet.enabled); + assert!(faucet.private_key.is_none()); + assert!(faucet.amount_wei.is_none()); + assert!(faucet.cooldown_minutes.is_none()); + } + + #[test] + fn faucet_config_validates_enabled_fields() { + let _lock = ENV_LOCK.lock().unwrap(); + set_valid_faucet_env(); + + let faucet = FaucetConfig::from_env().unwrap(); + assert!(faucet.enabled); + assert_eq!(faucet.cooldown_minutes, Some(30)); + assert_eq!( + faucet.amount_wei, + Some(U256::from(1_500_000_000_000_000_000u128)) + ); + + env::set_var("FAUCET_AMOUNT", "0.123456789123456789"); + let faucet = FaucetConfig::from_env().unwrap(); + assert_eq!( + faucet.amount_wei, + Some(U256::from(123_456_789_123_456_789u128)) + ); + } + + #[test] + fn faucet_config_rejects_bad_inputs() { + let _lock = ENV_LOCK.lock().unwrap(); + set_valid_faucet_env(); + + for (key, value, expected) in [ + ("FAUCET_ENABLED", "not-a-bool", "Invalid FAUCET_ENABLED"), + ("FAUCET_PRIVATE_KEY", "0x1234", "Invalid FAUCET_PRIVATE_KEY"), + ( + "FAUCET_AMOUNT", + "abc", + "FAUCET_AMOUNT must be a decimal ETH value", + ), + ( + "FAUCET_AMOUNT", + "1.0000000000000000001", + "supports at most 18 decimal places", + ), + ("FAUCET_COOLDOWN_MINUTES", "0", "must be greater than 0"), + ] { + set_valid_faucet_env(); + env::set_var(key, value); + let err = FaucetConfig::from_env().unwrap_err(); + assert!( + err.to_string().contains(expected), + "expected {expected} for {key}={value}" + ); + } + } } diff --git a/backend/crates/atlas-server/src/faucet.rs b/backend/crates/atlas-server/src/faucet.rs new file mode 100644 index 0000000..685a745 --- /dev/null +++ b/backend/crates/atlas-server/src/faucet.rs @@ -0,0 +1,345 @@ +use alloy::network::Ethereum; +use alloy::primitives::{Address, U256}; +use alloy::providers::{Provider, WalletProvider}; +use alloy::rpc::types::TransactionRequest; +use atlas_common::AtlasError; +use futures::future::{BoxFuture, FutureExt}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; + +const MAX_COOLDOWN_KEYS: usize = 4096; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct FaucetInfo { + pub amount_wei: String, + pub balance_wei: String, + pub cooldown_minutes: u64, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct FaucetTxResponse { + pub tx_hash: String, +} + +pub type SharedFaucetBackend = Arc; + +pub trait FaucetBackend: Send + Sync { + fn info(&self) -> BoxFuture<'static, Result>; + fn request_faucet( + &self, + recipient: Address, + client_ip: String, + ) -> BoxFuture<'static, Result>; +} + +pub struct FaucetService

{ + provider: Arc

, + amount_wei: U256, + cooldown_minutes: u64, + cooldown_duration: Duration, + cooldowns: Arc>, +} + +impl

FaucetService

+where + P: Provider + WalletProvider + Send + Sync + 'static, +{ + pub fn new(provider: P, amount_wei: U256, cooldown_minutes: u64) -> Self { + let cooldown_duration = Duration::from_secs(cooldown_minutes * 60); + Self { + provider: Arc::new(provider), + amount_wei, + cooldown_minutes, + cooldown_duration, + cooldowns: Arc::new(Mutex::new(CooldownState::new(MAX_COOLDOWN_KEYS))), + } + } +} + +impl

FaucetBackend for FaucetService

+where + P: Provider + WalletProvider + Send + Sync + 'static, +{ + fn info(&self) -> BoxFuture<'static, Result> { + let provider = Arc::clone(&self.provider); + let amount_wei = self.amount_wei; + let cooldown_minutes = self.cooldown_minutes; + + async move { + let balance = provider + .get_balance(provider.default_signer_address()) + .await + .map_err(|err| AtlasError::Rpc(err.to_string()))?; + + Ok(FaucetInfo { + amount_wei: amount_wei.to_string(), + balance_wei: balance.to_string(), + cooldown_minutes, + }) + } + .boxed() + } + + fn request_faucet( + &self, + recipient: Address, + client_ip: String, + ) -> BoxFuture<'static, Result> { + let provider = Arc::clone(&self.provider); + let amount_wei = self.amount_wei; + let cooldown_duration = self.cooldown_duration; + let cooldowns = Arc::clone(&self.cooldowns); + let address_key = recipient.to_checksum(None); + let ip_key = client_ip; + + async move { + let reservation = { + let mut cooldowns = cooldowns.lock().await; + cooldowns.acquire(address_key.clone(), ip_key.clone(), cooldown_duration)? + }; + + let tx = TransactionRequest::default() + .to(recipient) + .value(amount_wei); + match provider.send_transaction(tx).await { + Ok(pending) => Ok(FaucetTxResponse { + tx_hash: pending.tx_hash().to_string(), + }), + Err(err) => { + let mut cooldowns = cooldowns.lock().await; + cooldowns.rollback(&reservation); + Err(AtlasError::Rpc(err.to_string())) + } + } + } + .boxed() + } +} + +#[derive(Debug, Clone)] +struct Reservation { + address_key: String, + ip_key: String, + expiry: Instant, +} + +#[derive(Debug)] +struct CooldownState { + address_store: CooldownStore, + ip_store: CooldownStore, +} + +impl CooldownState { + fn new(max_entries: usize) -> Self { + Self { + address_store: CooldownStore::new(max_entries), + ip_store: CooldownStore::new(max_entries), + } + } + + fn acquire( + &mut self, + address_key: String, + ip_key: String, + ttl: Duration, + ) -> Result { + let now = Instant::now(); + self.address_store.cleanup(now); + self.ip_store.cleanup(now); + + let retry_after = self + .address_store + .retry_after(&address_key, now) + .into_iter() + .chain(self.ip_store.retry_after(&ip_key, now)) + .max(); + + if let Some(retry_after) = retry_after { + return Err(cooldown_error(retry_after)); + } + + let expiry = now + ttl; + self.address_store.reserve(address_key.clone(), expiry); + self.ip_store.reserve(ip_key.clone(), expiry); + + Ok(Reservation { + address_key, + ip_key, + expiry, + }) + } + + fn rollback(&mut self, reservation: &Reservation) { + self.address_store + .release_if_matches(&reservation.address_key, reservation.expiry); + self.ip_store + .release_if_matches(&reservation.ip_key, reservation.expiry); + } +} + +#[derive(Debug)] +struct CooldownStore { + max_entries: usize, + entries: HashMap, + expiries: BTreeMap>, +} + +impl CooldownStore { + fn new(max_entries: usize) -> Self { + Self { + max_entries, + entries: HashMap::new(), + expiries: BTreeMap::new(), + } + } + + fn cleanup(&mut self, now: Instant) { + while let Some(expiry) = self.expiries.keys().next().copied() { + if expiry > now { + break; + } + + let Some(keys) = self.expiries.remove(&expiry) else { + break; + }; + + for key in keys { + if self.entries.get(&key).copied() == Some(expiry) { + self.entries.remove(&key); + } + } + } + } + + fn retry_after(&self, key: &str, now: Instant) -> Option { + self.entries + .get(key) + .and_then(|expiry| expiry.checked_duration_since(now)) + } + + fn reserve(&mut self, key: String, expiry: Instant) { + if let Some(old_expiry) = self.entries.get(&key).copied() { + self.remove_from_index(old_expiry, &key); + } else if self.entries.len() >= self.max_entries { + self.evict_oldest(); + } + + self.entries.insert(key.clone(), expiry); + self.expiries.entry(expiry).or_default().insert(key); + } + + fn release_if_matches(&mut self, key: &str, expiry: Instant) { + if self.entries.get(key).copied() != Some(expiry) { + return; + } + + self.entries.remove(key); + self.remove_from_index(expiry, key); + } + + fn evict_oldest(&mut self) { + let Some(expiry) = self.expiries.keys().next().copied() else { + return; + }; + let Some(key) = self + .expiries + .get(&expiry) + .and_then(|keys| keys.iter().next().cloned()) + else { + return; + }; + + self.entries.remove(&key); + self.remove_from_index(expiry, &key); + } + + fn remove_from_index(&mut self, expiry: Instant, key: &str) { + let mut remove_bucket = false; + if let Some(keys) = self.expiries.get_mut(&expiry) { + keys.remove(key); + remove_bucket = keys.is_empty(); + } + + if remove_bucket { + self.expiries.remove(&expiry); + } + } +} + +fn cooldown_error(retry_after: Duration) -> AtlasError { + AtlasError::TooManyRequests { + message: "Faucet cooldown active".to_string(), + retry_after_seconds: duration_to_retry_after_seconds(retry_after), + } +} + +fn duration_to_retry_after_seconds(duration: Duration) -> u64 { + duration + .as_secs() + .saturating_add(u64::from(duration.subsec_nanos() > 0)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cooldown_store_expires_entries() { + let ttl = Duration::from_secs(60); + let mut store = CooldownStore::new(4); + let now = Instant::now(); + let key = "0xabc".to_string(); + + store.reserve(key.clone(), now + ttl); + assert_eq!(store.retry_after(&key, now), Some(ttl)); + + store.cleanup(now + ttl + Duration::from_secs(1)); + assert_eq!( + store.retry_after(&key, now + ttl + Duration::from_secs(1)), + None + ); + } + + #[test] + fn cooldown_store_evicts_when_full() { + let ttl = Duration::from_secs(60); + let mut store = CooldownStore::new(1); + let now = Instant::now(); + + store.reserve("first".to_string(), now + ttl); + store.reserve("second".to_string(), now + ttl); + + assert!(!store.entries.contains_key("first")); + assert!(store.entries.contains_key("second")); + } + + #[test] + fn state_rejects_active_address_or_ip() { + let ttl = Duration::from_secs(30); + let mut state = CooldownState::new(8); + let address = "0x0000000000000000000000000000000000000001".to_string(); + let ip = "127.0.0.1".to_string(); + + let reservation = state.acquire(address.clone(), ip.clone(), ttl).unwrap(); + let err = state.acquire(address.clone(), ip.clone(), ttl).unwrap_err(); + + match err { + AtlasError::TooManyRequests { + retry_after_seconds, + .. + } => assert!(retry_after_seconds > 0), + other => panic!("expected too many requests, got {other:?}"), + } + + state.rollback(&reservation); + assert!(state.acquire(address, ip, ttl).is_ok()); + } + + #[test] + fn duration_rounds_up_partial_seconds() { + assert_eq!(duration_to_retry_after_seconds(Duration::from_millis(1)), 1); + assert_eq!(duration_to_retry_after_seconds(Duration::from_secs(5)), 5); + } +} diff --git a/backend/crates/atlas-server/src/main.rs b/backend/crates/atlas-server/src/main.rs index 7f5f899..4de168e 100644 --- a/backend/crates/atlas-server/src/main.rs +++ b/backend/crates/atlas-server/src/main.rs @@ -4,8 +4,12 @@ use std::time::Duration; use tokio::sync::broadcast; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use alloy::providers::ProviderBuilder; +use alloy::signers::local::PrivateKeySigner; + mod api; mod config; +mod faucet; mod head; mod indexer; @@ -55,6 +59,30 @@ async fn main() -> Result<()> { // Load configuration dotenvy::dotenv().ok(); let config = config::Config::from_env()?; + let faucet_config = config::FaucetConfig::from_env()?; + + let faucet = if faucet_config.enabled { + tracing::info!("Faucet enabled"); + let private_key = faucet_config + .private_key + .as_ref() + .expect("validated faucet private key"); + let signer: PrivateKeySigner = private_key.parse().expect("validated faucet private key"); + let rpc_url: reqwest::Url = config + .rpc_url + .parse() + .map_err(|e| anyhow::anyhow!("Invalid RPC_URL for faucet: {e}"))?; + let provider = ProviderBuilder::new().wallet(signer).connect_http(rpc_url); + Some(Arc::new(faucet::FaucetService::new( + provider, + faucet_config.amount_wei.expect("validated faucet amount"), + faucet_config + .cooldown_minutes + .expect("validated faucet cooldown"), + )) as Arc) + } else { + None + }; tracing::info!("Fetching chain ID from RPC"); let chain_id = fetch_chain_id(&config.rpc_url).await?; @@ -85,6 +113,7 @@ async fn main() -> Result<()> { block_events_tx: block_events_tx.clone(), head_tracker: head_tracker.clone(), rpc_url: config.rpc_url.clone(), + faucet, chain_id, chain_name: config.chain_name.clone(), }); diff --git a/docker-compose.yml b/docker-compose.yml index 1e9a455..4e4be78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,10 @@ services: FETCH_WORKERS: ${FETCH_WORKERS:-10} RPC_REQUESTS_PER_SECOND: ${RPC_REQUESTS_PER_SECOND:-100} RPC_BATCH_SIZE: ${RPC_BATCH_SIZE:-20} + FAUCET_ENABLED: ${FAUCET_ENABLED:-false} + FAUCET_PRIVATE_KEY: ${FAUCET_PRIVATE_KEY:-} + FAUCET_AMOUNT: ${FAUCET_AMOUNT:-} + FAUCET_COOLDOWN_MINUTES: ${FAUCET_COOLDOWN_MINUTES:-} CHAIN_NAME: ${CHAIN_NAME:-Unknown} API_HOST: 0.0.0.0 API_PORT: 3000 diff --git a/frontend/bun.lock b/frontend/bun.lock index 30af1bb..0f4a6f4 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -5,7 +5,7 @@ "": { "name": "frontend", "dependencies": { - "axios": "^1.13.2", + "axios": "^1.13.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^6.30.3", @@ -278,7 +278,7 @@ "autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="], - "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/frontend/package.json b/frontend/package.json index 422e183..e6a8eff 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "preview": "vite preview" }, "dependencies": { - "axios": "^1.13.2", + "axios": "^1.13.6", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^6.30.3" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c5c2cd..aaf7c0a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import { WelcomePage, SearchResultsPage, AddressesPage, + FaucetPage, StatusPage, } from './pages'; import { ThemeProvider } from './context/ThemeContext'; @@ -41,6 +42,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8e13b59..d79802f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -17,8 +17,12 @@ client.interceptors.response.use( (response) => response, (error: AxiosError) => { if (error.response) { + const data = error.response.data as Partial | undefined; + const retryAfterSeconds = parseRetryAfterSeconds(error.response.headers, data); const apiError: ApiError = { - error: error.response.data?.error || error.message, + error: data?.error || error.message, + status: error.response.status, + ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}), }; return Promise.reject(apiError); } @@ -36,4 +40,42 @@ client.interceptors.response.use( } ); +function parseRetryAfterSeconds( + headers: unknown, + data: Partial | undefined +): number | undefined { + const bodyRetryAfter = (data as { retry_after_seconds?: unknown } | undefined)?.retry_after_seconds; + const headerRetryAfter = getHeaderValue(headers, 'retry-after'); + const rawRetryAfter = bodyRetryAfter ?? headerRetryAfter; + + if (typeof rawRetryAfter === 'number' && Number.isFinite(rawRetryAfter) && rawRetryAfter >= 0) { + return rawRetryAfter; + } + + if (typeof rawRetryAfter === 'string') { + const parsed = Number(rawRetryAfter); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + + const retryDate = Date.parse(rawRetryAfter); + if (!Number.isNaN(retryDate)) { + return Math.max(0, Math.ceil((retryDate - Date.now()) / 1000)); + } + } + + return undefined; +} + +function getHeaderValue(headers: unknown, key: string): unknown { + if (!headers || typeof headers !== 'object') return undefined; + const headerObject = headers as { get?: (name: string) => unknown } & Record; + + if (typeof headerObject.get === 'function') { + return headerObject.get(key); + } + + return headerObject[key] ?? headerObject[key.toLowerCase()] ?? headerObject[key.toUpperCase()]; +} + export default client; diff --git a/frontend/src/api/faucet.ts b/frontend/src/api/faucet.ts new file mode 100644 index 0000000..fd5d8ca --- /dev/null +++ b/frontend/src/api/faucet.ts @@ -0,0 +1,12 @@ +import client from './client'; +import type { FaucetInfo, FaucetRequestResponse } from '../types'; + +export async function getFaucetInfo(): Promise { + const response = await client.get('/faucet/info'); + return response.data; +} + +export async function requestFaucet(address: string): Promise { + const response = await client.post('/faucet', { address }); + return response.data; +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 7b2ca04..462dbcf 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -5,4 +5,5 @@ export * from './nfts'; export * from './tokens'; export * from './logs'; export * from './search'; +export * from './faucet'; export { default as client } from './client'; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index dcf2fa8..b6ac519 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,15 +2,19 @@ import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'; import { useMemo } from 'react'; import SearchBar from './SearchBar'; import useBlockSSE from '../hooks/useBlockSSE'; +import useFaucetInfo from '../hooks/useFaucetInfo'; import SmoothCounter from './SmoothCounter'; import logoImg from '../assets/logo.png'; import { BlockStatsContext } from '../context/BlockStatsContext'; +import { FaucetInfoContext } from '../context/FaucetInfoContext'; import { useTheme } from '../hooks/useTheme'; export default function Layout() { const location = useLocation(); const isHome = location.pathname === '/'; const sse = useBlockSSE(); + const faucetInfoResult = useFaucetInfo(); + const { faucetInfo } = faucetInfoResult; const blockTimeLabel = useMemo(() => { if (sse.bps !== null && sse.bps > 0) { @@ -64,6 +68,11 @@ export default function Layout() { Status + {faucetInfo && ( + + Faucet + + )} {/* Right status: latest height + live pulse */} @@ -139,6 +148,11 @@ export default function Layout() { Status + {faucetInfo && ( + + Faucet + + )} +

+ Enter a checksummed or lowercase EVM address. Empty or malformed inputs are rejected. +

+ + + + +
+ {infoCards.map((card) => ( +
+

{card.label}

+

{card.value}

+

{card.hint}

+
+ ))} +
+ + {cooldownBanner} + + {txHash && ( +
+

Transaction sent

+

+ Faucet transfer broadcast successfully. Track it here: +

+
+ +
+
+ )} + + + ); +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 2725ab4..d895e36 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -13,4 +13,5 @@ export { default as NotFoundPage } from './NotFoundPage'; export { default as WelcomePage } from './WelcomePage'; export { default as SearchResultsPage } from './SearchResultsPage'; export { default as AddressesPage } from './AddressesPage'; +export { default as FaucetPage } from './FaucetPage'; export { default as StatusPage } from './StatusPage'; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 23574be..c7e3563 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -166,6 +166,19 @@ export interface SearchResponse { export interface ApiError { error: string; + status?: number; + retryAfterSeconds?: number; +} + +// Faucet types +export interface FaucetInfo { + amount_wei: string; + balance_wei: string; + cooldown_minutes: number; +} + +export interface FaucetRequestResponse { + tx_hash: string; } // ERC-20 Token types diff --git a/frontend/src/utils/apiError.ts b/frontend/src/utils/apiError.ts new file mode 100644 index 0000000..09d1f3d --- /dev/null +++ b/frontend/src/utils/apiError.ts @@ -0,0 +1,16 @@ +import type { ApiError } from '../types'; + +export function toApiError(error: unknown, fallback: string): ApiError { + if (typeof error === 'object' && error !== null && 'error' in error && typeof (error as { error?: unknown }).error === 'string') { + const apiError = error as ApiError; + return { + error: apiError.error, + ...(typeof apiError.status === 'number' ? { status: apiError.status } : {}), + ...(typeof apiError.retryAfterSeconds === 'number' ? { retryAfterSeconds: apiError.retryAfterSeconds } : {}), + }; + } + + return { + error: error instanceof Error ? error.message : fallback, + }; +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 16c5b2b..d754c32 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1 +1,2 @@ export * from './format'; +export * from './apiError';