From 18297c09332e81ee9b73197ab1a26f906dcf7ad5 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Thu, 22 Oct 2026 00:01:01 +0100 Subject: [PATCH 1/2] fix(sdk): map React SDK stream fetches to the backend detail contract (#1704) - Fetch stream details via /:streamId detail endpoint regardless of configured Soroban RPC URL - Decouple REST endpoints from Soroban RPC configuration in useStream - Implement mapBackendStreamToFlowFiStream to convert backend stream contract to FlowFiStream - Clean up previous stream state on ID switch and ignore stale responses from cancelled requests - Handle 404 Not Found error responses gracefully - Add comprehensive test suite covering configured providers, response mapping, 404s, and stream ID switching --- packages/flowfi-react/src/hooks/useStream.ts | 158 ++++++++++++- packages/flowfi-react/src/index.ts | 2 +- .../flowfi-react/tests/useStream.test.tsx | 218 ++++++++++++++++++ 3 files changed, 367 insertions(+), 11 deletions(-) create mode 100644 packages/flowfi-react/tests/useStream.test.tsx diff --git a/packages/flowfi-react/src/hooks/useStream.ts b/packages/flowfi-react/src/hooks/useStream.ts index 4d13b8a8..fa97d0d7 100644 --- a/packages/flowfi-react/src/hooks/useStream.ts +++ b/packages/flowfi-react/src/hooks/useStream.ts @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'; import type { FlowFiStream } from '../types'; -import { useFlowFiConfig } from '../components/FlowFiProvider'; export interface UseStreamResult { stream: FlowFiStream | null; @@ -11,37 +10,176 @@ export interface UseStreamResult { refetch: () => void; } -export function useStream(streamId: string | undefined, endpoint = '/api/v1/streams'): UseStreamResult { - const { rpcUrl } = useFlowFiConfig(); +/** + * Maps a backend stream response (from GET /api/v1/streams/:streamId) into a FlowFiStream. + */ +export function mapBackendStreamToFlowFiStream(raw: unknown): FlowFiStream | null { + if (!raw || typeof raw !== 'object') return null; + + const data = ('stream' in raw && raw.stream && typeof raw.stream === 'object' + ? raw.stream + : 'data' in raw && raw.data && typeof raw.data === 'object' + ? raw.data + : raw) as Record; + + const id = String(data.streamId ?? data.id ?? ''); + if (!id) return null; + + const sender = String(data.sender ?? ''); + const recipient = String(data.recipient ?? ''); + + const rawToken = + data.token && typeof data.token === 'object' + ? (data.token as Record) + : null; + + const token = { + symbol: String(rawToken?.symbol ?? data.tokenSymbol ?? 'TOKEN'), + address: String(rawToken?.address ?? data.tokenAddress ?? ''), + decimals: Number(rawToken?.decimals ?? data.tokenDecimals ?? 7), + }; + + const depositedAmount = String(data.depositedAmount ?? '0'); + const withdrawnAmount = String(data.withdrawnAmount ?? '0'); + + let remainingBalance = + typeof data.remainingBalance === 'string' ? data.remainingBalance : ''; + if (!remainingBalance) { + try { + const dep = BigInt(depositedAmount.split('.')[0] || '0'); + const wth = BigInt(withdrawnAmount.split('.')[0] || '0'); + remainingBalance = dep >= wth ? (dep - wth).toString() : '0'; + } catch { + remainingBalance = '0'; + } + } + + const claimableBalance = + typeof data.claimableBalance === 'string' + ? data.claimableBalance + : typeof data.claimableAmount === 'string' + ? data.claimableAmount + : '0'; + + const startTimestamp = Number(data.startTimestamp ?? data.startTime ?? 0); + let endTimestamp = Number(data.endTimestamp ?? data.endTime ?? 0); + if (!endTimestamp && data.ratePerSecond && data.ratePerSecond !== '0') { + try { + const rate = BigInt(String(data.ratePerSecond).split('.')[0] || '0'); + const dep = BigInt(depositedAmount.split('.')[0] || '0'); + if (rate > 0n) { + endTimestamp = startTimestamp + Number(dep / rate); + } + } catch { + // ignore + } + } + + const cliffTimestamp = Number( + data.cliffTimestamp ?? data.cliffDate ?? data.cliffTime ?? 0, + ); + + let status: FlowFiStream['status'] = 'active'; + if (typeof data.status === 'string') { + const s = data.status.toLowerCase(); + if (s === 'active' || s === 'paused' || s === 'completed' || s === 'cancelled') { + status = s; + } + } else if (data.isPaused) { + status = 'paused'; + } else if (data.isCancelled) { + status = 'cancelled'; + } else if (data.isActive === false) { + status = 'completed'; + } + + const usdRate = data.usdRate !== undefined ? Number(data.usdRate) : undefined; + + return { + id, + sender, + recipient, + token, + depositedAmount, + withdrawnAmount, + remainingBalance, + claimableBalance, + startTimestamp, + endTimestamp, + cliffTimestamp, + status, + ...(usdRate !== undefined ? { usdRate } : {}), + }; +} + +export function useStream( + streamId: string | undefined, + endpoint = '/api/v1/streams', +): UseStreamResult { const [stream, setStream] = useState(null); const [isLoading, setLoading] = useState(false); const [error, setError] = useState(null); const [nonce, setNonce] = useState(0); useEffect(() => { - if (!streamId) return; + if (!streamId) { + setStream(null); + setError(null); + setLoading(false); + return; + } + let cancelled = false; setLoading(true); + setError(null); + setStream(null); + void (async () => { try { - const url = rpcUrl ? `${endpoint}?id=${streamId}` : `${endpoint}/${streamId}`; + const cleanEndpoint = endpoint.replace(/\/+$/, ''); + const url = `${cleanEndpoint}/${encodeURIComponent(streamId)}`; const res = await fetch(url, { headers: { Accept: 'application/json' } }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + + if (!res.ok) { + let message = `HTTP ${res.status}`; + try { + const errorBody = await res.json(); + if (errorBody?.error?.message) { + message = errorBody.error.message; + } else if (errorBody?.message) { + message = errorBody.message; + } + } catch { + // ignore non-JSON error body + } + if (res.status === 404 && !message.toLowerCase().includes('not found')) { + message = `Stream not found (${message})`; + } + throw new Error(message); + } + const json = await res.json(); if (!cancelled) { - setStream(json?.stream ?? json ?? null); + const mapped = mapBackendStreamToFlowFiStream(json); + setStream(mapped); setError(null); } } catch (err) { - if (!cancelled) setError(err instanceof Error ? err : new Error('Failed to load stream')); + if (!cancelled) { + setStream(null); + setError(err instanceof Error ? err : new Error('Failed to load stream')); + } } finally { - if (!cancelled) setLoading(false); + if (!cancelled) { + setLoading(false); + } } })(); + return () => { cancelled = true; }; - }, [streamId, endpoint, rpcUrl, nonce]); + }, [streamId, endpoint, nonce]); return { stream, isLoading, error, refetch: () => setNonce((n) => n + 1) }; } diff --git a/packages/flowfi-react/src/index.ts b/packages/flowfi-react/src/index.ts index 9f8cd7b5..98464e45 100644 --- a/packages/flowfi-react/src/index.ts +++ b/packages/flowfi-react/src/index.ts @@ -10,7 +10,7 @@ export type { export { formatUnits, computeClaimable } from './utils/math'; // Hooks -export { useStream } from './hooks/useStream'; +export { useStream, mapBackendStreamToFlowFiStream } from './hooks/useStream'; export { useClaimableBalance } from './hooks/useClaimableBalance'; export { useCreateStream } from './hooks/useCreateStream'; export { useBatchWithdraw } from './hooks/useBatchWithdraw'; diff --git a/packages/flowfi-react/tests/useStream.test.tsx b/packages/flowfi-react/tests/useStream.test.tsx new file mode 100644 index 00000000..2382b566 --- /dev/null +++ b/packages/flowfi-react/tests/useStream.test.tsx @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import React, { type ReactNode } from 'react'; +import { useStream, mapBackendStreamToFlowFiStream } from '../src/hooks/useStream'; +import { FlowFiProvider } from '../src/components/FlowFiProvider'; + +describe('useStream hook', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + const mockBackendStream = { + id: 'uuid-1234', + streamId: 42, + sender: 'GA_SENDER_123', + recipient: 'GB_RECIPIENT_456', + tokenAddress: 'CD_TOKEN_789', + ratePerSecond: '1000', + depositedAmount: '5000000', + withdrawnAmount: '1000000', + startTime: 1700000000, + lastUpdateTime: 1700000100, + endTime: 1700005000, + isActive: true, + isPaused: false, + pausedAt: null, + totalPausedDuration: 0, + }; + + it('fetches stream via detail endpoint regardless of configured Soroban RPC URL', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockBackendStream, + }); + global.fetch = fetchMock; + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useStream('42'), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestUrl = fetchMock.mock.calls[0]![0]; + // Must call the detail endpoint /api/v1/streams/42, NOT a query param ?id=42 + expect(requestUrl).toBe('/api/v1/streams/42'); + expect(requestUrl).not.toContain('?id='); + expect(result.current.stream?.id).toBe('42'); + }); + + it('maps real backend response into FlowFiStream contract', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockBackendStream, + }); + global.fetch = fetchMock; + + const { result } = renderHook(() => useStream('42')); + + await waitFor(() => expect(result.current.stream).not.toBeNull()); + + const stream = result.current.stream!; + expect(stream.id).toBe('42'); + expect(stream.sender).toBe('GA_SENDER_123'); + expect(stream.recipient).toBe('GB_RECIPIENT_456'); + expect(stream.token).toEqual({ + address: 'CD_TOKEN_789', + symbol: 'TOKEN', + decimals: 7, + }); + expect(stream.depositedAmount).toBe('5000000'); + expect(stream.withdrawnAmount).toBe('1000000'); + expect(stream.remainingBalance).toBe('4000000'); + expect(stream.claimableBalance).toBe('0'); + expect(stream.startTimestamp).toBe(1700000000); + expect(stream.endTimestamp).toBe(1700005000); + expect(stream.status).toBe('active'); + }); + + it('handles not-found (404) response gracefully', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ + error: { + code: 'NOT_FOUND', + message: 'Stream not found', + }, + }), + }); + global.fetch = fetchMock; + + const { result } = renderHook(() => useStream('999')); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.stream).toBeNull(); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toMatch(/not found/i); + }); + + it('handles switching stream IDs and cancels stale responses', async () => { + let resolveFirst: ((val: unknown) => void) | undefined; + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + + const stream2Backend = { + ...mockBackendStream, + id: 'uuid-2', + streamId: 2, + }; + + const fetchMock = vi.fn((url: string) => { + if (url.includes('/1')) { + return firstPromise; + } + return Promise.resolve({ + ok: true, + status: 200, + json: async () => stream2Backend, + }); + }); + global.fetch = fetchMock as unknown as typeof fetch; + + const { result, rerender } = renderHook(({ id }) => useStream(id), { + initialProps: { id: '1' }, + }); + + expect(result.current.isLoading).toBe(true); + + // Switch ID to '2' before '1' resolves + rerender({ id: '2' }); + + await waitFor(() => expect(result.current.stream?.id).toBe('2')); + expect(result.current.stream?.id).toBe('2'); + + // Late resolve for stream 1 + resolveFirst!({ + ok: true, + status: 200, + json: async () => mockBackendStream, + }); + + // Verify stream 2 is still current + expect(result.current.stream?.id).toBe('2'); + }); + + it('does not fetch when streamId is undefined', () => { + const fetchMock = vi.fn(); + global.fetch = fetchMock; + + const { result } = renderHook(() => useStream(undefined)); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result.current.stream).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); +}); + +describe('mapBackendStreamToFlowFiStream', () => { + it('maps paused and completed status correctly', () => { + const paused = mapBackendStreamToFlowFiStream({ + streamId: '1', + isPaused: true, + isActive: true, + depositedAmount: '100', + withdrawnAmount: '0', + }); + expect(paused?.status).toBe('paused'); + + const completed = mapBackendStreamToFlowFiStream({ + streamId: '2', + isPaused: false, + isActive: false, + depositedAmount: '100', + withdrawnAmount: '100', + }); + expect(completed?.status).toBe('completed'); + }); + + it('maps custom token and usdRate if provided', () => { + const stream = mapBackendStreamToFlowFiStream({ + streamId: '10', + token: { + symbol: 'USDC', + address: '0xusdc', + decimals: 6, + }, + usdRate: 1.05, + }); + expect(stream?.token).toEqual({ + symbol: 'USDC', + address: '0xusdc', + decimals: 6, + }); + expect(stream?.usdRate).toBe(1.05); + }); +}); From 143b6e19c50725fd413dde48c6696bb0cc0eb42b Mon Sep 17 00:00:00 2001 From: intelliDean Date: Thu, 22 Oct 2026 01:35:34 +0100 Subject: [PATCH 2/2] fix(sdk): replace polynomial regex with linear trailing slash trim (#1704) --- packages/flowfi-react/src/hooks/useStream.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/flowfi-react/src/hooks/useStream.ts b/packages/flowfi-react/src/hooks/useStream.ts index fa97d0d7..555b29a1 100644 --- a/packages/flowfi-react/src/hooks/useStream.ts +++ b/packages/flowfi-react/src/hooks/useStream.ts @@ -136,7 +136,10 @@ export function useStream( void (async () => { try { - const cleanEndpoint = endpoint.replace(/\/+$/, ''); + let cleanEndpoint = endpoint; + while (cleanEndpoint.endsWith('/')) { + cleanEndpoint = cleanEndpoint.slice(0, -1); + } const url = `${cleanEndpoint}/${encodeURIComponent(streamId)}`; const res = await fetch(url, { headers: { Accept: 'application/json' } });