diff --git a/libs/ui-components/README.md b/libs/ui-components/README.md
index c29136a..980cecf 100644
--- a/libs/ui-components/README.md
+++ b/libs/ui-components/README.md
@@ -157,6 +157,34 @@ import { BridgeHistory } from '@bridgewise/ui-components';
;
```
+### Real-Time Transaction Status
+
+In addition to viewing historical records, you can subscribe to live updates for a specific transaction. This is useful when you need to show a spinner, send notifications or update other parts of your UI as the bridge work progresses (e.g. refresh quotes, run slippage checks).
+
+```tsx
+import { useTransactionStatus } from '@bridgewise/ui-components';
+
+function TransactionTracker({ txId }: { txId: string }) {
+ const { status, loading, error, lastUpdate } = useTransactionStatus(txId, {
+ pollingIntervalMs: 3000,
+ notifications: true, // browser notification when status changes
+ onStatusChange: (s) => console.log('status:', s),
+ });
+
+ return (
+
+ {loading &&
Connecting...}
+
Status: {status || 'unknown'}
+ {error &&
{error.message}
}
+ {lastUpdate &&
Updated {lastUpdate.toISOString()}}
+
+ );
+}
+```
+
+Support also exists for storing status updates in the same history used by `useTransactionHistory` via the `historyConfig`/`account` options.
+
+### Storage configuration
### Storage configuration
By default, history is persisted in browser local storage.
diff --git a/libs/ui-components/src/hooks/__tests__/useTransactionStatus.spec.ts b/libs/ui-components/src/hooks/__tests__/useTransactionStatus.spec.ts
new file mode 100644
index 0000000..819601e
--- /dev/null
+++ b/libs/ui-components/src/hooks/__tests__/useTransactionStatus.spec.ts
@@ -0,0 +1,133 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+import { useTransactionStatus, UseTransactionStatusOptions } from '../useTransactionStatus';
+import type { BridgeTransactionStatus } from '../../transaction-history/types';
+
+describe('useTransactionStatus', () => {
+ let originalEventSource: any;
+ let eventSources: any[];
+
+ beforeEach(() => {
+ // capture the original so we can restore later
+ originalEventSource = (global as any).EventSource;
+ eventSources = [];
+
+ // simple fake EventSource implementation
+ class FakeEventSource {
+ onmessage: ((evt: any) => void) | null = null;
+ onerror: ((evt: any) => void) | null = null;
+ constructor(public url: string) {
+ eventSources.push(this);
+ }
+ close() {
+ // nothing
+ }
+ }
+
+ (global as any).EventSource = FakeEventSource;
+
+ // default fetch mock
+ (global as any).fetch = jest.fn(() =>
+ Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'pending' }) }),
+ );
+ });
+
+ afterEach(() => {
+ (global as any).EventSource = originalEventSource;
+ jest.resetAllMocks();
+ jest.useRealTimers();
+ });
+
+ it('returns null status initially and does not crash on server', () => {
+ const { result } = renderHook(() => useTransactionStatus(null));
+ expect(result.current.status).toBeNull();
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('is SSR-safe when window is undefined', () => {
+ const prev = (global as any).window;
+ // @ts-ignore remove window
+ delete (global as any).window;
+ const { result } = renderHook(() => useTransactionStatus('tx', {}));
+ expect(result.current.status).toBeNull();
+ expect(result.current.loading).toBe(false);
+ (global as any).window = prev;
+ });
+
+ it('subscribes to SSE and updates status on message', () => {
+ const { result } = renderHook(() => useTransactionStatus('tx123'));
+
+ expect(eventSources.length).toBe(1);
+ act(() => {
+ eventSources[0].onmessage({ data: JSON.stringify({ status: 'confirmed' }) });
+ });
+ expect(result.current.status).toBe('confirmed');
+ expect(result.current.loading).toBe(false);
+ });
+
+ it('calls onStatusChange callback and notifications handler', () => {
+ const changes: BridgeTransactionStatus[] = [];
+ const notifs: BridgeTransactionStatus[] = [];
+ const options: UseTransactionStatusOptions = {
+ onStatusChange: (s) => changes.push(s),
+ notifications: (s) => notifs.push(s),
+ };
+ renderHook(() => useTransactionStatus('txABC', options));
+ act(() => {
+ eventSources[0].onmessage({ data: JSON.stringify({ status: 'failed' }) });
+ });
+ expect(changes).toEqual(['failed']);
+ expect(notifs).toEqual(['failed']);
+ });
+
+ it('maps unknown statuses to pending', () => {
+ const { result } = renderHook(() => useTransactionStatus('txUnknown'));
+ act(() => {
+ eventSources[0].onmessage({ data: JSON.stringify({ status: 'in-progress' }) });
+ });
+ expect(result.current.status).toBe('pending');
+ });
+
+ it('updates history storage when status changes', () => {
+ const backend = {
+ saveTransaction: jest.fn(() => Promise.resolve()),
+ getTransactionsByAccount: jest.fn(() => Promise.resolve([])),
+ } as any;
+ const { result } = renderHook(() =>
+ useTransactionStatus('txHistory', {
+ historyConfig: { backend },
+ account: 'acct1',
+ }),
+ );
+
+ act(() => {
+ eventSources[0].onmessage({ data: JSON.stringify({ status: 'confirmed' }) });
+ });
+
+ // storage should have been called with at least the status and account
+ expect(backend.saveTransaction).toHaveBeenCalled();
+ });
+
+ it('falls back to polling when SSE errors', async () => {
+ jest.useFakeTimers();
+ const { result } = renderHook(() => useTransactionStatus('txpoll', { pollingIntervalMs: 100 }));
+ // trigger error to force polling
+ act(() => {
+ eventSources[0].onerror && eventSources[0].onerror(new Event('error'));
+ });
+
+ // verify fetch gets called immediately and eventually
+ expect((global as any).fetch).toHaveBeenCalledWith('/transactions/txpoll/poll');
+ // make fetch return confirmed status next time
+ (global as any).fetch.mockImplementationOnce(() =>
+ Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'confirmed' }) }),
+ );
+ // advance timers to run polling interval
+ await act(async () => {
+ jest.advanceTimersByTime(150);
+ // wait for promise resolution
+ await Promise.resolve();
+ });
+
+ expect(result.current.status).toBe('confirmed');
+ });
+});
diff --git a/libs/ui-components/src/hooks/useTransactionStatus.ts b/libs/ui-components/src/hooks/useTransactionStatus.ts
new file mode 100644
index 0000000..453b312
--- /dev/null
+++ b/libs/ui-components/src/hooks/useTransactionStatus.ts
@@ -0,0 +1,202 @@
+'use client';
+
+import { useEffect, useState, useRef } from 'react';
+import type {
+ BridgeTransactionStatus,
+} from '../transaction-history/types';
+import { TransactionHistoryStorage } from '../transaction-history/storage';
+import type { TransactionHistoryConfig, BridgeTransaction } from '../transaction-history/types';
+
+export interface UseTransactionStatusOptions {
+ /** Interval to poll when SSE is unavailable or fails */
+ pollingIntervalMs?: number;
+ /** Callback invoked whenever status changes */
+ onStatusChange?: (status: BridgeTransactionStatus) => void;
+ /** Enable desktop/browser notification or custom handler */
+ notifications?: boolean | ((status: BridgeTransactionStatus) => void);
+ /** Optional configuration used to persist status to history storage */
+ historyConfig?: TransactionHistoryConfig;
+ /** Account for history persistence (required when historyConfig is provided) */
+ account?: string;
+}
+
+export interface UseTransactionStatusReturn {
+ status: BridgeTransactionStatus | null;
+ loading: boolean;
+ error?: Error;
+ lastUpdate: Date | null;
+}
+
+const DEFAULT_POLL_INTERVAL = 5000;
+
+/**
+ * Hook for tracking the real-time status of a transaction by its ID/Hash.
+ * It attempts to open an EventSource connection to `/transactions/:id/events`
+ * and falls back to polling `/transactions/:id/poll` if SSE fails or is not available.
+ *
+ * The hook is SSR-safe (will do nothing during server rendering).
+ */
+export function useTransactionStatus(
+ txId: string | null | undefined,
+ options: UseTransactionStatusOptions = {},
+): UseTransactionStatusReturn {
+ const [status, setStatus] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(undefined);
+ const [lastUpdate, setLastUpdate] = useState(null);
+
+ const eventSourceRef = useRef(null);
+ const pollingRef = useRef(null);
+ const historyStorageRef = useRef(null);
+
+ // lazily initialise history storage if needed
+ useEffect(() => {
+ if (options.historyConfig) {
+ historyStorageRef.current = new TransactionHistoryStorage(options.historyConfig);
+ }
+ }, [options.historyConfig]);
+
+ useEffect(() => {
+ if (!txId || typeof window === 'undefined') {
+ return;
+ }
+
+ let active = true;
+ setLoading(true);
+
+ const notify = (newStatus: BridgeTransactionStatus) => {
+ options.onStatusChange?.(newStatus);
+ if (options.notifications) {
+ if (typeof options.notifications === 'function') {
+ options.notifications(newStatus);
+ } else if (typeof Notification !== 'undefined') {
+ if (Notification.permission === 'granted') {
+ new Notification(`Transaction status: ${newStatus}`);
+ } else if (Notification.permission !== 'denied') {
+ Notification.requestPermission().then((perm) => {
+ if (perm === 'granted') {
+ new Notification(`Transaction status: ${newStatus}`);
+ }
+ });
+ }
+ }
+ }
+ // update history if config supplied
+ if (historyStorageRef.current && options.account) {
+ const record: Partial = {
+ txHash: txId,
+ status: newStatus,
+ timestamp: new Date(),
+ account: options.account,
+ // other fields unknown; history storage will fill defaults
+ } as any;
+ historyStorageRef.current.upsertTransaction(record as BridgeTransaction).catch(() => {
+ /* swallow */
+ });
+ }
+ };
+
+ const handleMessage = (e: MessageEvent) => {
+ try {
+ const data = JSON.parse(e.data);
+ if (data && data.status) {
+ // guard against unexpected status values; default to pending
+ const candidate = data.status as string;
+ const allowed: BridgeTransactionStatus[] = ['pending', 'confirmed', 'failed'];
+ const newStatus: BridgeTransactionStatus = allowed.includes(candidate as any)
+ ? (candidate as BridgeTransactionStatus)
+ : 'pending';
+
+ setStatus(newStatus);
+ setLastUpdate(new Date());
+ setLoading(false);
+ notify(newStatus);
+ if (newStatus !== 'pending') {
+ cleanup();
+ }
+ }
+ } catch (err) {
+ // ignore malformed payload
+ }
+ };
+
+ const handleError = () => {
+ // SSE connection failed; fall back to polling
+ cleanupEventSource();
+ startPolling();
+ };
+
+ const cleanupEventSource = () => {
+ if (eventSourceRef.current) {
+ eventSourceRef.current.close();
+ eventSourceRef.current = null;
+ }
+ };
+
+ const startPolling = () => {
+ const poll = async () => {
+ try {
+ const resp = await fetch(`/transactions/${txId}/poll`);
+ if (!resp.ok) throw new Error('poll failed');
+ const data = await resp.json();
+ if (data && data.status) {
+ const candidate = data.status as string;
+ const allowed: BridgeTransactionStatus[] = ['pending', 'confirmed', 'failed'];
+ const newStatus: BridgeTransactionStatus = allowed.includes(candidate as any)
+ ? (candidate as BridgeTransactionStatus)
+ : 'pending';
+
+ setStatus(newStatus);
+ setLastUpdate(new Date());
+ setLoading(false);
+ notify(newStatus);
+ if (newStatus !== 'pending') {
+ stopPolling();
+ }
+ }
+ } catch (err) {
+ setError(err as Error);
+ }
+ };
+ poll();
+ const interval = options.pollingIntervalMs ?? DEFAULT_POLL_INTERVAL;
+ pollingRef.current = window.setInterval(poll, interval);
+ };
+
+ const stopPolling = () => {
+ if (pollingRef.current !== null) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ };
+
+ const cleanup = () => {
+ cleanupEventSource();
+ stopPolling();
+ setLoading(false);
+ };
+
+ // try SSE first
+ try {
+ const es = new EventSource(`/transactions/${txId}/events`);
+ eventSourceRef.current = es;
+ es.onmessage = handleMessage;
+ es.onerror = handleError;
+ } catch (err) {
+ // if construction throws, fallback to polling
+ startPolling();
+ }
+
+ return () => {
+ active = false;
+ cleanup();
+ };
+ }, [txId, options.onStatusChange, options.notifications, options.pollingIntervalMs, options.account]);
+
+ return {
+ status,
+ loading,
+ error,
+ lastUpdate,
+ };
+}
diff --git a/libs/ui-components/src/types/testing-library-react-hooks.d.ts b/libs/ui-components/src/types/testing-library-react-hooks.d.ts
new file mode 100644
index 0000000..5eccf2c
--- /dev/null
+++ b/libs/ui-components/src/types/testing-library-react-hooks.d.ts
@@ -0,0 +1,3 @@
+// declare module to satisfy TypeScript during tests
+
+declare module '@testing-library/react-hooks';