diff --git a/backend/crates/atlas-server/src/api/handlers/config.rs b/backend/crates/atlas-server/src/api/handlers/config.rs index 13792ed..c541d83 100644 --- a/backend/crates/atlas-server/src/api/handlers/config.rs +++ b/backend/crates/atlas-server/src/api/handlers/config.rs @@ -4,6 +4,20 @@ use std::sync::Arc; use crate::api::AppState; +#[derive(Serialize)] +pub struct ChainFeatures { + pub da_tracking: bool, +} + +#[derive(Serialize)] +pub struct FaucetConfig { + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount_wei: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cooldown_minutes: Option, +} + #[derive(Serialize)] pub struct BrandingConfig { pub chain_name: String, @@ -23,6 +37,8 @@ pub struct BrandingConfig { pub success_color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error_color: Option, + pub features: ChainFeatures, + pub faucet: FaucetConfig, } /// GET /api/config - Returns white-label branding configuration @@ -38,6 +54,14 @@ pub async fn get_config(State(state): State>) -> Json Result<(i64, String), /// Returns in <1ms, optimized for frequent polling. pub async fn get_height(State(state): State>) -> ApiResult> { let (block_height, indexed_at) = latest_height_and_indexed_at(&state).await?; - let features = ChainFeatures { - da_tracking: state.da_tracking_enabled, - }; Ok(Json(HeightResponse { block_height, indexed_at, - features, })) } @@ -122,6 +112,8 @@ mod tests { rpc_url: String::new(), da_tracking_enabled: false, faucet: None, + faucet_amount_wei: None, + faucet_cooldown_minutes: None, chain_id: 1, chain_name: "Test Chain".to_string(), chain_logo_url: None, @@ -149,7 +141,6 @@ mod tests { assert_eq!(status.block_height, 42); assert!(!status.indexed_at.is_empty()); - assert!(!status.features.da_tracking); } #[tokio::test] diff --git a/backend/crates/atlas-server/src/api/mod.rs b/backend/crates/atlas-server/src/api/mod.rs index 465fc7b..e189ba5 100644 --- a/backend/crates/atlas-server/src/api/mod.rs +++ b/backend/crates/atlas-server/src/api/mod.rs @@ -24,6 +24,8 @@ pub struct AppState { pub rpc_url: String, pub da_tracking_enabled: bool, pub faucet: Option, + pub faucet_amount_wei: Option, + pub faucet_cooldown_minutes: Option, pub chain_id: u64, pub chain_name: String, pub chain_logo_url: Option, @@ -285,6 +287,8 @@ mod tests { rpc_url: String::new(), da_tracking_enabled: false, faucet, + faucet_amount_wei: None, + faucet_cooldown_minutes: None, chain_id: 1, chain_name: "Test Chain".to_string(), chain_logo_url: None, diff --git a/backend/crates/atlas-server/src/main.rs b/backend/crates/atlas-server/src/main.rs index b2c3c70..5b732bf 100644 --- a/backend/crates/atlas-server/src/main.rs +++ b/backend/crates/atlas-server/src/main.rs @@ -285,6 +285,8 @@ async fn run(args: cli::RunArgs) -> Result<()> { let config = config::Config::from_run_args(args.clone())?; let faucet_config = config::FaucetConfig::from_faucet_args(&args.faucet)?; let snapshot_config = config::SnapshotConfig::from_env(&config.database_url)?; + let faucet_amount_wei = faucet_config.amount_wei.as_ref().map(ToString::to_string); + let faucet_cooldown_minutes = faucet_config.cooldown_minutes; let faucet = if faucet_config.enabled { tracing::info!("Faucet enabled"); @@ -358,6 +360,8 @@ async fn run(args: cli::RunArgs) -> Result<()> { rpc_url: config.rpc_url.clone(), da_tracking_enabled: config.da_tracking_enabled, faucet, + faucet_amount_wei, + faucet_cooldown_minutes, chain_id, chain_name: config.chain_name.clone(), chain_logo_url: config.chain_logo_url.clone(), diff --git a/backend/crates/atlas-server/tests/integration/common.rs b/backend/crates/atlas-server/tests/integration/common.rs index 1ffee00..54778bb 100644 --- a/backend/crates/atlas-server/tests/integration/common.rs +++ b/backend/crates/atlas-server/tests/integration/common.rs @@ -104,6 +104,8 @@ pub fn test_router() -> Router { rpc_url: String::new(), da_tracking_enabled: false, faucet: None, + faucet_amount_wei: None, + faucet_cooldown_minutes: None, chain_id: 42, chain_name: "Test Chain".to_string(), chain_logo_url: None, diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index e3f43e5..85bea75 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -1,4 +1,11 @@ -import client from './client'; +import client from "./client"; +import type { ChainFeatures } from "../types"; + +export interface FaucetConfig { + enabled: boolean; + amount_wei?: string; + cooldown_minutes?: number; +} export interface BrandingConfig { chain_name: string; @@ -10,8 +17,56 @@ export interface BrandingConfig { background_color_light?: string; success_color?: string; error_color?: string; + features: ChainFeatures; + faucet: FaucetConfig; +} + +const defaultFeatures: ChainFeatures = { da_tracking: false }; +const defaultFaucet: FaucetConfig = { enabled: false }; + +function normalizeFeatures(value: unknown): ChainFeatures { + if (!value || typeof value !== "object") { + return defaultFeatures; + } + + const features = value as Partial; + return { + da_tracking: features.da_tracking === true, + }; +} + +function normalizeFaucet(value: unknown): FaucetConfig { + if (!value || typeof value !== "object") { + return defaultFaucet; + } + + const faucet = value as Partial; + return { + enabled: faucet.enabled === true, + ...(typeof faucet.amount_wei === "string" + ? { amount_wei: faucet.amount_wei } + : {}), + ...(Number.isFinite(faucet.cooldown_minutes) && + Number.isInteger(faucet.cooldown_minutes) && + faucet.cooldown_minutes >= 0 + ? { cooldown_minutes: faucet.cooldown_minutes } + : {}), + }; +} + +export function normalizeBrandingConfig( + config: Partial & Pick, +): BrandingConfig { + return { + ...config, + features: normalizeFeatures(config.features), + faucet: normalizeFaucet(config.faucet), + }; } export async function getConfig(): Promise { - return client.get('/config'); + const config = await client.get< + Partial & Pick + >("/config"); + return normalizeBrandingConfig(config); } diff --git a/frontend/src/api/status.ts b/frontend/src/api/status.ts index e97e55b..db21c77 100644 --- a/frontend/src/api/status.ts +++ b/frontend/src/api/status.ts @@ -1,10 +1,8 @@ -import client from './client'; -import type { ChainFeatures } from '../types'; +import client from "./client"; export interface HeightResponse { block_height: number; indexed_at?: string; // ISO timestamp, absent when no blocks indexed - features: ChainFeatures; } export interface ChainStatusResponse { @@ -17,9 +15,9 @@ export interface ChainStatusResponse { } export async function getHeight(): Promise { - return client.get('/height'); + return client.get("/height"); } export async function getChainStatus(): Promise { - return client.get('/status'); + return client.get("/status"); } diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index e384bcc..6c5ac16 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,21 +1,17 @@ -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 defaultLogoImg from '../assets/logo.png'; -import { BlockStatsContext } from '../context/BlockStatsContext'; -import { FaucetInfoContext } from '../context/FaucetInfoContext'; -import { useTheme } from '../hooks/useTheme'; -import { useBranding } from '../hooks/useBranding'; +import { Link, NavLink, Outlet, useLocation } from "react-router-dom"; +import { useMemo } from "react"; +import SearchBar from "./SearchBar"; +import useBlockSSE from "../hooks/useBlockSSE"; +import SmoothCounter from "./SmoothCounter"; +import defaultLogoImg from "../assets/logo.png"; +import { BlockStatsContext } from "../context/BlockStatsContext"; +import { useTheme } from "../hooks/useTheme"; +import { useBranding } from "../hooks/useBranding"; export default function Layout() { const location = useLocation(); - const isHome = location.pathname === '/'; + const isHome = location.pathname === "/"; const sse = useBlockSSE(); - const faucetInfoResult = useFaucetInfo(); - const { faucetInfo } = faucetInfoResult; const blockTimeLabel = useMemo(() => { if (sse.bps !== null && sse.bps > 0) { @@ -25,17 +21,17 @@ export default function Layout() { } return `${secs.toFixed(1)} s`; } - return '—'; + return "—"; }, [sse.bps]); const navLinkClass = ({ isActive }: { isActive: boolean }) => `inline-flex items-center h-10 px-4 rounded-full leading-none transition-colors duration-150 ${ isActive - ? 'bg-dark-700/70 text-fg' - : 'text-gray-400 hover:text-fg hover:bg-dark-700/40' + ? "bg-dark-700/70 text-fg" + : "text-gray-400 hover:text-fg hover:bg-dark-700/40" }`; const { theme, toggleTheme } = useTheme(); - const isDark = theme === 'dark'; - const { chainName, logoUrl } = useBranding(); + const isDark = theme === "dark"; + const { chainName, logoUrl, faucet } = useBranding(); const logoSrc = logoUrl || defaultLogoImg; return ( @@ -46,8 +42,16 @@ export default function Layout() {
{/* Logo */}
- - {chainName} + + {chainName}
@@ -71,7 +75,7 @@ export default function Layout() { Status - {faucetInfo && ( + {faucet.enabled && ( Faucet @@ -83,7 +87,9 @@ export default function Layout() {
| - + {blockTimeLabel}
@@ -151,7 +161,7 @@ export default function Layout() { Status - {faucetInfo && ( + {faucet.enabled && ( Faucet @@ -159,7 +169,9 @@ export default function Layout() {
diff --git a/frontend/src/context/BrandingContext.tsx b/frontend/src/context/BrandingContext.tsx index 3b3b976..7de96c2 100644 --- a/frontend/src/context/BrandingContext.tsx +++ b/frontend/src/context/BrandingContext.tsx @@ -1,17 +1,30 @@ -import { useEffect, useState, useContext, type ReactNode } from 'react'; -import { getConfig, type BrandingConfig } from '../api/config'; -import { deriveSurfaceShades, applyPalette, hexToRgbTriplet } from '../utils/color'; -import { ThemeContext } from './theme-context'; -import { BrandingContext, brandingDefaults } from './branding-context'; -import { resolveBrandingValue, resolveLogoUrl } from './branding'; -import defaultLogoImg from '../assets/logo.png'; +import { useEffect, useState, useContext, type ReactNode } from "react"; +import { + getConfig, + normalizeBrandingConfig, + type BrandingConfig, +} from "../api/config"; +import { + deriveSurfaceShades, + applyPalette, + hexToRgbTriplet, +} from "../utils/color"; +import { ThemeContext } from "./theme-context"; +import { BrandingContext, brandingDefaults } from "./branding-context"; +import { resolveBrandingValue, resolveLogoUrl } from "./branding"; +import defaultLogoImg from "../assets/logo.png"; -const CACHE_KEY = 'branding_config'; +const CACHE_KEY = "branding_config"; function readCache(): BrandingConfig | null { try { const raw = localStorage.getItem(CACHE_KEY); - return raw ? (JSON.parse(raw) as BrandingConfig) : null; + return raw + ? normalizeBrandingConfig( + JSON.parse(raw) as Partial & + Pick, + ) + : null; } catch { return null; } @@ -20,9 +33,9 @@ function readCache(): BrandingConfig | null { function setFavicon(href: string) { let link = document.querySelector("link[rel='icon']"); if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - link.type = 'image/png'; + link = document.createElement("link"); + link.rel = "icon"; + link.type = "image/png"; document.head.appendChild(link); } link.href = href; @@ -30,7 +43,7 @@ function setFavicon(href: string) { export function BrandingProvider({ children }: { children: ReactNode }) { const themeCtx = useContext(ThemeContext); - const theme = themeCtx?.theme ?? 'dark'; + const theme = themeCtx?.theme ?? "dark"; const [branding, setBranding] = useState(() => { const cached = readCache(); return cached ? resolveBrandingValue(cached, theme) : brandingDefaults; @@ -47,7 +60,7 @@ export function BrandingProvider({ children }: { children: ReactNode }) { .catch(() => { if (!config) setBranding({ ...brandingDefaults, loaded: true }); }); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -75,25 +88,31 @@ export function BrandingProvider({ children }: { children: ReactNode }) { } }; - setRgbVar('--color-accent-primary', config.accent_color); - setRgbVar('--color-accent-success', config.success_color); - setRgbVar('--color-accent-error', config.error_color); + setRgbVar("--color-accent-primary", config.accent_color); + setRgbVar("--color-accent-success", config.success_color); + setRgbVar("--color-accent-error", config.error_color); }, [config]); // Apply background palette reactively on theme change useEffect(() => { if (!config) return; - if (theme === 'dark' && config.background_color_dark) { + if (theme === "dark" && config.background_color_dark) { try { - const palette = deriveSurfaceShades(config.background_color_dark, 'dark'); + const palette = deriveSurfaceShades( + config.background_color_dark, + "dark", + ); applyPalette(palette); } catch { // fall through to default CSS vars } - } else if (theme === 'light' && config.background_color_light) { + } else if (theme === "light" && config.background_color_light) { try { - const palette = deriveSurfaceShades(config.background_color_light, 'light'); + const palette = deriveSurfaceShades( + config.background_color_light, + "light", + ); applyPalette(palette); } catch { // fall through to default CSS vars @@ -102,11 +121,19 @@ export function BrandingProvider({ children }: { children: ReactNode }) { // Remove any inline overrides so the CSS defaults take effect const root = document.documentElement; const vars = [ - '--color-surface-900', '--color-surface-800', '--color-surface-700', - '--color-surface-600', '--color-surface-500', '--color-body-bg', - '--color-body-text', '--color-border', '--color-text-primary', - '--color-text-secondary', '--color-text-muted', '--color-text-subtle', - '--color-text-faint', + "--color-surface-900", + "--color-surface-800", + "--color-surface-700", + "--color-surface-600", + "--color-surface-500", + "--color-body-bg", + "--color-body-text", + "--color-border", + "--color-text-primary", + "--color-text-secondary", + "--color-text-muted", + "--color-text-subtle", + "--color-text-faint", ]; vars.forEach((v) => { root.style.removeProperty(v); @@ -118,22 +145,22 @@ export function BrandingProvider({ children }: { children: ReactNode }) { return (
diff --git a/frontend/src/context/FaucetInfoContext.tsx b/frontend/src/context/FaucetInfoContext.tsx deleted file mode 100644 index f29921f..0000000 --- a/frontend/src/context/FaucetInfoContext.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createContext } from 'react'; -import type { UseFaucetInfoResult } from '../hooks/useFaucetInfo'; - -const noop = async () => {}; - -export const FaucetInfoContext = createContext({ - faucetInfo: null, - loading: false, - error: null, - notFound: false, - refetch: noop, -}); diff --git a/frontend/src/context/branding-context.ts b/frontend/src/context/branding-context.ts index d35aa7d..7ead4c3 100644 --- a/frontend/src/context/branding-context.ts +++ b/frontend/src/context/branding-context.ts @@ -1,17 +1,24 @@ -import { createContext } from 'react'; +import { createContext } from "react"; +import type { FaucetConfig } from "../api/config"; +import type { ChainFeatures } from "../types"; export interface BrandingContextValue { chainName: string; logoUrl: string | null; accentHex: string | null; + features: ChainFeatures; + faucet: FaucetConfig; loaded: boolean; } export const brandingDefaults: BrandingContextValue = { - chainName: 'Unknown', + chainName: "Unknown", logoUrl: null, accentHex: null, + features: { da_tracking: false }, + faucet: { enabled: false }, loaded: false, }; -export const BrandingContext = createContext(brandingDefaults); +export const BrandingContext = + createContext(brandingDefaults); diff --git a/frontend/src/context/branding.ts b/frontend/src/context/branding.ts index ef9738e..55c4cbc 100644 --- a/frontend/src/context/branding.ts +++ b/frontend/src/context/branding.ts @@ -1,16 +1,20 @@ -import type { BrandingConfig } from '../api/config'; -import type { BrandingContextValue } from './branding-context'; -import type { Theme } from './theme-context'; +import type { BrandingConfig } from "../api/config"; +import type { BrandingContextValue } from "./branding-context"; +import type { Theme } from "./theme-context"; export function resolveLogoUrl( - config: Pick, + config: Pick, theme: Theme, ): string | null { - if (theme === 'light') { - return config.logo_url_light ?? config.logo_url ?? config.logo_url_dark ?? null; + if (theme === "light") { + return ( + config.logo_url_light ?? config.logo_url ?? config.logo_url_dark ?? null + ); } - return config.logo_url_dark ?? config.logo_url ?? config.logo_url_light ?? null; + return ( + config.logo_url_dark ?? config.logo_url ?? config.logo_url_light ?? null + ); } export function resolveBrandingValue( @@ -21,6 +25,8 @@ export function resolveBrandingValue( chainName: config.chain_name, logoUrl: resolveLogoUrl(config, theme), accentHex: config.accent_color ?? null, + features: config.features, + faucet: config.faucet, loaded: true, }; } diff --git a/frontend/src/hooks/useFaucetInfo.ts b/frontend/src/hooks/useFaucetInfo.ts index e13de3b..594c71c 100644 --- a/frontend/src/hooks/useFaucetInfo.ts +++ b/frontend/src/hooks/useFaucetInfo.ts @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useState } from 'react'; -import { getFaucetInfo } from '../api/faucet'; -import type { ApiError, FaucetInfo } from '../types'; -import { toApiError } from '../utils'; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getFaucetInfo } from "../api/faucet"; +import type { ApiError, FaucetInfo } from "../types"; +import { toApiError } from "../utils"; export interface UseFaucetInfoResult { faucetInfo: FaucetInfo | null; @@ -11,42 +11,69 @@ export interface UseFaucetInfoResult { refetch: (options?: { background?: boolean }) => Promise; } -export default function useFaucetInfo(): UseFaucetInfoResult { +export default function useFaucetInfo(enabled = true): UseFaucetInfoResult { const [faucetInfo, setFaucetInfo] = useState(null); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(enabled); const [error, setError] = useState(null); const [notFound, setNotFound] = useState(false); + const requestVersionRef = useRef(0); - const fetchInfo = useCallback(async (options?: { background?: boolean }) => { - const background = options?.background ?? false; - if (!background) { - setLoading(true); - setError(null); - setNotFound(false); - } - - try { - const info = await getFaucetInfo(); - setFaucetInfo(info); - } catch (err: unknown) { - if (background) return; - const apiError = toApiError(err, 'Failed to load faucet information'); - if (apiError.status === 404) { - setFaucetInfo(null); - setNotFound(true); - } else { - setError(apiError); + const fetchInfo = useCallback( + async (options?: { background?: boolean }) => { + const requestVersion = ++requestVersionRef.current; + if (!enabled) { + if (requestVersion === requestVersionRef.current) { + setFaucetInfo(null); + setLoading(false); + setError(null); + setNotFound(false); + } + return; } - } finally { + + const background = options?.background ?? false; if (!background) { - setLoading(false); + setLoading(true); + setError(null); + setNotFound(false); } - } - }, []); + + try { + const info = await getFaucetInfo(); + if (requestVersion !== requestVersionRef.current) return; + setError(null); + setNotFound(false); + setFaucetInfo(info); + } catch (err: unknown) { + if (background || requestVersion !== requestVersionRef.current) return; + const apiError = toApiError(err, "Failed to load faucet information"); + if (apiError.status === 404) { + setFaucetInfo(null); + setNotFound(true); + } else { + setError(apiError); + } + } finally { + if (!background && requestVersion === requestVersionRef.current) { + setLoading(false); + } + } + }, + [enabled], + ); useEffect(() => { + if (!enabled) { + requestVersionRef.current += 1; + setFaucetInfo(null); + setLoading(false); + setError(null); + setNotFound(false); + return; + } + void fetchInfo(); - }, [fetchInfo]); + }, [enabled, fetchInfo]); return { faucetInfo, loading, error, notFound, refetch: fetchInfo }; } diff --git a/frontend/src/hooks/useFeatures.ts b/frontend/src/hooks/useFeatures.ts index f9d3987..cdbb5d4 100644 --- a/frontend/src/hooks/useFeatures.ts +++ b/frontend/src/hooks/useFeatures.ts @@ -1,68 +1,5 @@ -import { useEffect, useState } from 'react'; -import { getHeight } from '../api/status'; -import type { ChainFeatures } from '../types'; +import { useBranding } from "./useBranding"; -const defaultFeatures: ChainFeatures = { da_tracking: false }; -type FeaturesListener = (features: ChainFeatures) => void; - -let cachedFeatures: ChainFeatures | null = null; -let featuresPromise: Promise | null = null; -const listeners = new Set(); - -function notifyFeatures(features: ChainFeatures) { - for (const listener of listeners) { - listener(features); - } -} - -function loadFeatures(): Promise { - if (cachedFeatures) { - return Promise.resolve(cachedFeatures); - } - - if (!featuresPromise) { - featuresPromise = getHeight() - .then((status) => status.features ?? defaultFeatures) - .catch(() => defaultFeatures) - .then((features) => { - cachedFeatures = features; - notifyFeatures(features); - return features; - }) - .finally(() => { - featuresPromise = null; - }); - } - - return featuresPromise; -} - -/** - * Returns cached chain feature flags, loading them only once per app session. - */ -export default function useFeatures(): ChainFeatures { - const [features, setFeatures] = useState(cachedFeatures ?? defaultFeatures); - - useEffect(() => { - let active = true; - const listener: FeaturesListener = (nextFeatures) => { - if (active) { - setFeatures(nextFeatures); - } - }; - - listeners.add(listener); - void loadFeatures().then((nextFeatures) => { - if (active) { - setFeatures(nextFeatures); - } - }); - - return () => { - active = false; - listeners.delete(listener); - }; - }, []); - - return features; +export default function useFeatures() { + return useBranding().features; } diff --git a/frontend/src/pages/FaucetPage.tsx b/frontend/src/pages/FaucetPage.tsx index d673143..34c65ee 100644 --- a/frontend/src/pages/FaucetPage.tsx +++ b/frontend/src/pages/FaucetPage.tsx @@ -1,10 +1,11 @@ -import { useContext, useEffect, useMemo, useState, type FormEvent } from 'react'; -import { TxHashLink, Loading, Error } from '../components'; -import { formatEtherExact, formatNumber, toApiError } from '../utils'; -import type { ApiError } from '../types'; -import { requestFaucet } from '../api/faucet'; -import { FaucetInfoContext } from '../context/FaucetInfoContext'; -import NotFoundPage from './NotFoundPage'; +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { TxHashLink, Loading, Error } from "../components"; +import { formatEtherExact, formatNumber, toApiError } from "../utils"; +import type { ApiError } from "../types"; +import { requestFaucet } from "../api/faucet"; +import useFaucetInfo from "../hooks/useFaucetInfo"; +import { useBranding } from "../hooks/useBranding"; +import NotFoundPage from "./NotFoundPage"; const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; @@ -29,8 +30,11 @@ function isValidAddress(address: string): boolean { } export default function FaucetPage() { - const { faucetInfo, loading, error, notFound, refetch } = useContext(FaucetInfoContext); - const [address, setAddress] = useState(''); + const { faucet, loaded } = useBranding(); + const { faucetInfo, loading, error, notFound, refetch } = useFaucetInfo( + faucet.enabled, + ); + const [address, setAddress] = useState(""); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [txHash, setTxHash] = useState(null); @@ -52,32 +56,50 @@ export default function FaucetPage() { setSubmitError((current) => (current?.status === 429 ? null : current)); }, [cooldownUntil, now]); - const cooldownRemainingSeconds = cooldownUntil ? Math.max(0, Math.ceil((cooldownUntil - now) / 1000)) : 0; - const canSubmit = Boolean(faucetInfo) && !submitting && cooldownRemainingSeconds === 0; + const cooldownRemainingSeconds = cooldownUntil + ? Math.max(0, Math.ceil((cooldownUntil - now) / 1000)) + : 0; + const canSubmit = + Boolean(faucetInfo) && !submitting && cooldownRemainingSeconds === 0; const infoCards = useMemo(() => { - if (!faucetInfo) return []; - - return [ - { - label: 'Balance', - value: `${formatEtherExact(faucetInfo.balance_wei).replace(/^(\d+\.\d{4})\d*$/, '$1')} ETH`, - hint: 'Current faucet wallet balance', - }, - { - label: 'Drip amount', - value: `${formatEtherExact(faucetInfo.amount_wei)} ETH`, - hint: 'Per successful request', - }, - { - label: 'Cooldown', - value: faucetInfo.cooldown_minutes === 1 ? '1 minute' : `${formatNumber(faucetInfo.cooldown_minutes)} minutes`, - hint: 'Per address and per IP', - }, - ]; - }, [faucetInfo]); - - if (notFound || disabled) { + const cards = []; + + if (faucetInfo?.balance_wei) { + cards.push({ + label: "Balance", + value: `${formatEtherExact(faucetInfo.balance_wei).replace(/^(\d+\.\d{4})\d*$/, "$1")} ETH`, + hint: "Current faucet wallet balance", + }); + } + + if (faucet.amount_wei) { + cards.push({ + label: "Drip amount", + value: `${formatEtherExact(faucet.amount_wei)} ETH`, + hint: "Per successful request", + }); + } + + if (typeof faucet.cooldown_minutes === "number") { + cards.push({ + label: "Cooldown", + value: + faucet.cooldown_minutes === 1 + ? "1 minute" + : `${formatNumber(faucet.cooldown_minutes)} minutes`, + hint: "Per address and per IP", + }); + } + + return cards; + }, [faucet.amount_wei, faucet.cooldown_minutes, faucetInfo?.balance_wei]); + + if (!loaded) { + return ; + } + + if (!faucet.enabled || notFound || disabled) { return ; } @@ -94,7 +116,7 @@ export default function FaucetPage() { const trimmedAddress = address.trim(); if (!isValidAddress(trimmedAddress)) { - setSubmitError({ error: 'Enter a valid EVM address.', status: 400 }); + setSubmitError({ error: "Enter a valid EVM address.", status: 400 }); return; } @@ -119,7 +141,7 @@ export default function FaucetPage() { setCooldownUntil(null); void refetch({ background: true }); } catch (err: unknown) { - const apiError = toApiError(err, 'Failed to request faucet funds'); + const apiError = toApiError(err, "Failed to request faucet funds"); if (apiError.status === 404) { setDisabled(true); return; @@ -136,20 +158,27 @@ export default function FaucetPage() { } }; - const cooldownBanner = submitError?.status === 429 ? ( -
-

Rate limited

-

{submitError.error}

-

- Try again in {formatCountdown(cooldownRemainingSeconds || (submitError.retryAfterSeconds ?? 0))}. -

-
- ) : submitError ? ( -
-

Request failed

-

{submitError.error}

-
- ) : null; + const cooldownBanner = + submitError?.status === 429 ? ( +
+

Rate limited

+

{submitError.error}

+

+ Try again in{" "} + + {formatCountdown( + cooldownRemainingSeconds || (submitError.retryAfterSeconds ?? 0), + )} + + . +

+
+ ) : submitError ? ( +
+

Request failed

+

{submitError.error}

+
+ ) : null; return (
@@ -160,17 +189,24 @@ export default function FaucetPage() {
-

Faucet

-

Request test ETH

+

+ Faucet +

+

+ Request test ETH +

- Drips are rate-limited per address and per IP. Use this faucet for test networks only. + Drips are rate-limited per address and per IP. Use this faucet + for test networks only.

@@ -204,8 +241,15 @@ export default function FaucetPage() {
{infoCards.map((card) => (
-

{card.label}

-

{card.value}

+

+ {card.label} +

+

+ {card.value} +

{card.hint}

))} @@ -215,7 +259,9 @@ export default function FaucetPage() { {txHash && (
-

Transaction sent

+

+ Transaction sent +

Faucet transfer broadcast successfully. Track it here:

diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 91fc875..128e77f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -21,7 +21,7 @@ export interface BlockDaStatus { updated_at: string; } -// Chain feature flags returned by /api/status +// Chain feature flags returned by /api/config export interface ChainFeatures { da_tracking: boolean; } @@ -48,7 +48,7 @@ export interface Address { first_seen_block: number; tx_count: number; // New fields from updated API - address_type?: 'eoa' | 'contract' | 'nft' | 'erc20'; + address_type?: "eoa" | "contract" | "nft" | "erc20"; name?: string | null; symbol?: string | null; total_supply?: string | null; @@ -112,11 +112,11 @@ export interface PaginatedResponse { } export interface SearchResult { - type: 'block' | 'transaction' | 'address' | 'nft' | 'nft_collection'; + type: "block" | "transaction" | "address" | "nft" | "nft_collection"; } export interface BlockSearchResult extends SearchResult { - type: 'block'; + type: "block"; number: number; hash: string; parent_hash: string; @@ -129,7 +129,7 @@ export interface BlockSearchResult extends SearchResult { } export interface TransactionSearchResult extends SearchResult { - type: 'transaction'; + type: "transaction"; hash: string; block_number: number; block_index: number; @@ -145,7 +145,7 @@ export interface TransactionSearchResult extends SearchResult { } export interface AddressSearchResult extends SearchResult { - type: 'address'; + type: "address"; address: string; is_contract: boolean; first_seen_block: number; @@ -153,7 +153,7 @@ export interface AddressSearchResult extends SearchResult { } export interface NftSearchResult extends SearchResult { - type: 'nft'; + type: "nft"; contract_address: string; token_id: string; owner: string; @@ -162,7 +162,7 @@ export interface NftSearchResult extends SearchResult { } export interface NftCollectionSearchResult extends SearchResult { - type: 'nft_collection'; + type: "nft_collection"; address: string; name: string | null; symbol: string | null; @@ -244,7 +244,7 @@ export interface AddressTransfer { value: string; // ERC-20 amount (raw) or NFT token ID block_number: number; timestamp: number; - transfer_type: 'erc20' | 'nft'; + transfer_type: "erc20" | "nft"; token_name: string | null; token_symbol: string | null; } @@ -294,7 +294,6 @@ export interface DecodedParam { indexed: boolean; } - // Proxy Contract types export interface ProxyInfo { proxy_address: string;