Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.2.0",
"@types/node": "^25.5.0",
"@types/pg": "^8.16.0",
"@types/supertest": "^6.0.3",
"@types/swagger-jsdoc": "^6.0.4",
Expand Down
15 changes: 11 additions & 4 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(", "))}`;
Expand All @@ -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.");
Expand Down
1 change: 1 addition & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions frontend/src/app/components/providers/WalletProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface WalletProviderContextValue {
connectWallet: () => Promise<void>;
disconnectWallet: () => void;
refreshWallet: () => Promise<void>;
signTransaction: (unsignedTxXdr: string) => Promise<string>;
isFreighterAvailable: boolean;
}

Expand Down Expand Up @@ -43,6 +44,13 @@ const NETWORK_CHAIN_IDS: Record<string, number> = {
STANDALONE: 4,
};

const NETWORK_PASSPHRASES: Record<string, string> = {
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;
Expand Down Expand Up @@ -95,6 +103,7 @@ async function loadFreighterApi(): Promise<FreighterApi> {

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);
Expand Down Expand Up @@ -218,6 +227,29 @@ export function WalletProvider({ children }: WalletProviderProps) {
await syncWallet(true);
}

async function signTransaction(unsignedTxXdr: string): Promise<string> {
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();
}
Expand Down Expand Up @@ -291,6 +323,7 @@ export function WalletProvider({ children }: WalletProviderProps) {
connectWallet,
disconnectWallet,
refreshWallet,
signTransaction,
isFreighterAvailable,
}}
>
Expand Down
52 changes: 48 additions & 4 deletions frontend/src/app/hooks/useSSE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
/** Full URL of the SSE endpoint. Pass null/undefined to disable. */
Expand All @@ -13,25 +13,34 @@ interface UseSSEOptions<T> {
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<T = unknown>({
url,
onMessage,
onOpen,
onError,
onPoll,
pollingInterval = 30_000,
}: UseSSEOptions<T>): SSEStatus {
const [status, setStatus] = useState<SSEStatus>("connecting");
const retryDelay = useRef(1_000);
const esRef = useRef<EventSource | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

// Keep callback refs stable so the effect doesn't need to re-run when they
// change, which would needlessly restart the connection.
Expand All @@ -41,14 +50,40 @@ export function useSSE<T = unknown>({
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 });
Expand All @@ -57,6 +92,7 @@ export function useSSE<T = unknown>({
es.onopen = () => {
retryDelay.current = 1_000;
setStatus("connected");
stopPolling(); // Stop fallback polling once stream is active
onOpenRef.current?.();
};

Expand All @@ -72,7 +108,14 @@ export function useSSE<T = unknown>({
es.onerror = (event) => {
es.close();
esRef.current = null;
setStatus("disconnected");

if (onPollRef.current) {
setStatus("fallback");
startPolling();
} else {
setStatus("disconnected");
}

onErrorRef.current?.(event);

if (!cancelled) {
Expand All @@ -89,9 +132,10 @@ export function useSSE<T = unknown>({
cancelled = true;
esRef.current?.close();
esRef.current = null;
stopPolling();
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [url]);
}, [url, pollingInterval]);

return status;
}
Loading