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

interface WalletProviderProps {
Expand Down Expand Up @@ -44,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 @@ -96,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 @@ -219,21 +227,11 @@ export function WalletProvider({ children }: WalletProviderProps) {
await syncWallet(true);
}

function disconnectWallet() {
disconnect();
}

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",
};

async function signTransaction(unsignedTxXdr: string): Promise<string> {
const api = await loadFreighterApi();
const networkName = useWalletStore.getState().network?.name ?? "TESTNET";
const networkPassphrase = NETWORK_PASSPHRASES[networkName] ?? NETWORK_PASSPHRASES.TESTNET;

const networkPassphrase =
NETWORK_PASSPHRASES[network?.name ?? "TESTNET"] || NETWORK_PASSPHRASES.TESTNET;

const result = await api.signTransaction(unsignedTxXdr, {
networkPassphrase,
Expand All @@ -243,15 +241,16 @@ export function WalletProvider({ children }: WalletProviderProps) {
return result;
}

if (result.error) {
if (result && typeof result === "object" && "error" in result) {
throw new Error(normalizeWalletError(result.error));
}

if (result.signedTxXdr) {
return result.signedTxXdr;
}

throw new Error("Signing failed: No signed transaction returned.");
function disconnectWallet() {
disconnect();
}

async function refreshWallet() {
Expand Down Expand Up @@ -323,8 +322,8 @@ export function WalletProvider({ children }: WalletProviderProps) {
connectWallet,
disconnectWallet,
refreshWallet,
isFreighterAvailable,
signTransaction,
isFreighterAvailable,
}}
>
{children}
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");
if (!cancelled) 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