diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 307e75d5..2badf5f9 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -29,8 +29,11 @@ export function validateEnvVars(): void { ); if (missing.length > 0) { - const boldRed = (msg: string) => `\x1b[1;31m${msg}\x1b[0m`; - const bold = (msg: string) => `\x1b[1m${msg}\x1b[0m`; + const isTest = process.env.NODE_ENV === "test"; + const useColor = process.stdout.isTTY && !isTest; + + const boldRed = (msg: string) => useColor ? `\x1b[1;31m${msg}\x1b[0m` : msg; + const bold = (msg: string) => useColor ? `\x1b[1m${msg}\x1b[0m` : msg; const errorPrefix = boldRed("FATAL ERROR: Environment validation failed"); const missingVarMsg = `Missing or empty required variables: ${bold(missing.join(", "))}`; @@ -45,8 +48,12 @@ export function validateEnvVars(): void { node_env: process.env.NODE_ENV, }); - // Stop execution immediately - process.exit(1); + if (isTest) { + throw new Error(`Environment validation failed: ${missing.join(", ")}`); + } else { + // Stop execution immediately in production/dev + process.exit(1); + } } logger.info("Environment variables validated successfully."); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8806f7ff..72451f07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13284,6 +13284,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/frontend/src/app/components/providers/WalletProvider.tsx b/frontend/src/app/components/providers/WalletProvider.tsx index 3dccb095..83b832d8 100644 --- a/frontend/src/app/components/providers/WalletProvider.tsx +++ b/frontend/src/app/components/providers/WalletProvider.tsx @@ -10,6 +10,7 @@ interface WalletProviderContextValue { connectWallet: () => Promise; disconnectWallet: () => void; refreshWallet: () => Promise; + signTransaction: (unsignedTxXdr: string) => Promise; isFreighterAvailable: boolean; } @@ -43,6 +44,13 @@ const NETWORK_CHAIN_IDS: Record = { STANDALONE: 4, }; +const NETWORK_PASSPHRASES: Record = { + PUBLIC: "Public Global Stellar Network ; October 2015", + TESTNET: "Test SDF Network ; September 2015", + FUTURENET: "Test SDF Future Network ; October 2022", + STANDALONE: "Standalone Network ; Separate from SDF", +}; + function normalizeWalletError(error: unknown): string { if (typeof error === "string" && error.length > 0) { return error; @@ -95,6 +103,7 @@ async function loadFreighterApi(): Promise { export function WalletProvider({ children }: WalletProviderProps) { const address = useWalletStore((state) => state.address); + const network = useWalletStore((state) => state.network); const shouldAutoReconnect = useWalletStore((state) => state.shouldAutoReconnect); const setConnected = useWalletStore((state) => state.setConnected); const disconnect = useWalletStore((state) => state.disconnect); @@ -218,6 +227,29 @@ export function WalletProvider({ children }: WalletProviderProps) { await syncWallet(true); } + async function signTransaction(unsignedTxXdr: string): Promise { + const api = await loadFreighterApi(); + + // Map the network name to the required passphrase string + const networkPassphrase = + NETWORK_PASSPHRASES[network?.name ?? "TESTNET"] || NETWORK_PASSPHRASES.TESTNET; + + const result = await api.signTransaction(unsignedTxXdr, { + networkPassphrase, + }); + + if (typeof result === "string") { + return result; + } + + // Handle potential error object returned by some versions of the API + if (result && typeof result === "object" && "error" in result) { + throw new Error(normalizeWalletError(result.error)); + } + + throw new Error("Failed to sign transaction or operation cancelled."); + } + function disconnectWallet() { disconnect(); } @@ -291,6 +323,7 @@ export function WalletProvider({ children }: WalletProviderProps) { connectWallet, disconnectWallet, refreshWallet, + signTransaction, isFreighterAvailable, }} > diff --git a/frontend/src/app/hooks/useSSE.ts b/frontend/src/app/hooks/useSSE.ts index 0291a1ee..75bbaccb 100644 --- a/frontend/src/app/hooks/useSSE.ts +++ b/frontend/src/app/hooks/useSSE.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; -export type SSEStatus = "connecting" | "connected" | "disconnected"; +export type SSEStatus = "connecting" | "connected" | "disconnected" | "fallback"; interface UseSSEOptions { /** Full URL of the SSE endpoint. Pass null/undefined to disable. */ @@ -13,25 +13,34 @@ interface UseSSEOptions { onOpen?: () => void; /** Called when the connection closes with an error. */ onError?: (event: Event) => void; + /** Optional polling fallback function called when SSE is unavailable or fails. */ + onPoll?: () => void; + /** Polling interval in ms. Defaults to 30s. */ + pollingInterval?: number; } /** - * Generic SSE hook with exponential backoff reconnection. + * Generic SSE hook with exponential backoff reconnection and optional polling fallback. * * Connects to `url` and calls `onMessage` with each parsed JSON payload. * Automatically reconnects on error, backing off up to 30 s. * Returns the current connection status for UI indicators. + * + * If `onPoll` is provided, it will be called every `pollingInterval` when the SSE stream is down. */ export function useSSE({ url, onMessage, onOpen, onError, + onPoll, + pollingInterval = 30_000, }: UseSSEOptions): SSEStatus { const [status, setStatus] = useState("connecting"); const retryDelay = useRef(1_000); const esRef = useRef(null); const timeoutRef = useRef | null>(null); + const pollIntervalRef = useRef | null>(null); // Keep callback refs stable so the effect doesn't need to re-run when they // change, which would needlessly restart the connection. @@ -41,14 +50,40 @@ export function useSSE({ onOpenRef.current = onOpen; const onErrorRef = useRef(onError); onErrorRef.current = onError; + const onPollRef = useRef(onPoll); + onPollRef.current = onPoll; useEffect(() => { if (!url) return; let cancelled = false; + const startPolling = () => { + if (!onPollRef.current || pollIntervalRef.current) return; + // Immediate poll to refresh data as soon as fallback starts + onPollRef.current(); + pollIntervalRef.current = setInterval(() => { + onPollRef.current?.(); + }, pollingInterval); + }; + + const stopPolling = () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + }; + function connect() { if (cancelled) return; + + // Fallback if browser doesn't support EventSource + if (typeof window !== "undefined" && !window.EventSource) { + setStatus("fallback"); + startPolling(); + return; + } + setStatus("connecting"); const es = new EventSource(url as string, { withCredentials: true }); @@ -57,6 +92,7 @@ export function useSSE({ es.onopen = () => { retryDelay.current = 1_000; setStatus("connected"); + stopPolling(); // Stop fallback polling once stream is active onOpenRef.current?.(); }; @@ -72,7 +108,14 @@ export function useSSE({ es.onerror = (event) => { es.close(); esRef.current = null; - setStatus("disconnected"); + + if (onPollRef.current) { + setStatus("fallback"); + startPolling(); + } else { + setStatus("disconnected"); + } + onErrorRef.current?.(event); if (!cancelled) { @@ -89,9 +132,10 @@ export function useSSE({ cancelled = true; esRef.current?.close(); esRef.current = null; + stopPolling(); if (timeoutRef.current) clearTimeout(timeoutRef.current); }; - }, [url]); + }, [url, pollingInterval]); return status; }