From 66335bcf1258b1968e392ba873947d4fe1871a3e Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 19:48:06 +1000 Subject: [PATCH 1/9] fix: normalize wallet errors safely --- src/context/AppContext.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 1983317..336af1b 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,9 +1,11 @@ + import { createContext, useContext, useEffect, useState } from 'react'; import { connectWallet, getStoredWallet, disconnectWallet, } from '../services/wallet.js'; +import { getUserErrorMessage, normalizeError } from '../services/errors.js'; import { useLocalStorage } from '../hooks/useLocalStorage.js'; import { DEFAULT_LOCALE, @@ -115,9 +117,8 @@ export function AppProvider({ children, connectTimeoutMs = 30000 }) { setWallet(account); return account; } catch (err) { - // Handle rejected connections (user cancellation, timeout, or other errors) - const errorMessage = err.message || 'Failed to connect wallet'; - setConnectionError(errorMessage); + const normalized = normalizeError(err, { source: 'wallet' }); + setConnectionError(getUserErrorMessage(normalized)); } finally { setConnecting(false); } From 8cef901b8be5a99a7b08e3b8612764ef98542c02 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 19:49:24 +1000 Subject: [PATCH 2/9] feat: add safe error normalization policy --- src/services/errors.js | 94 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/services/errors.js diff --git a/src/services/errors.js b/src/services/errors.js new file mode 100644 index 0000000..bab520f --- /dev/null +++ b/src/services/errors.js @@ -0,0 +1,94 @@ +export const ERROR_CODES = Object.freeze({ + WALLET_REJECTED: 'wallet_rejected', + TIMEOUT: 'timeout', + RATE_LIMITED: 'rate_limited', + UNAVAILABLE: 'unavailable', + UNKNOWN: 'unknown', +}); + +const SAFE_MESSAGES = Object.freeze({ + [ERROR_CODES.WALLET_REJECTED]: 'Wallet connection was cancelled.', + [ERROR_CODES.TIMEOUT]: 'The request timed out. Please try again.', + [ERROR_CODES.RATE_LIMITED]: 'Too many requests. Please wait and try again.', + [ERROR_CODES.UNAVAILABLE]: + 'The service is temporarily unavailable. Please try again.', + [ERROR_CODES.UNKNOWN]: 'Something went wrong. Please try again.', +}); + +function readStatus(error) { + const status = Number(error?.status ?? error?.response?.status); + return Number.isFinite(status) ? status : null; +} + +function readCorrelationId(error) { + const candidates = [ + error?.correlationId, + error?.requestId, + error?.response?.headers?.get?.('x-correlation-id'), + error?.response?.headers?.get?.('x-request-id'), + ]; + const value = candidates.find((candidate) => typeof candidate === 'string'); + if (!value) return null; + const trimmed = value.trim(); + return /^[A-Za-z0-9._:-]{1,128}$/.test(trimmed) ? trimmed : null; +} + +function readMessage(error) { + return typeof error?.message === 'string' ? error.message : ''; +} + +function isWalletRejected(error) { + const code = error?.code; + if (code === 4001 || code === '4001' || code === 'USER_REJECTED') return true; + return /user.*(reject|denied|cancel)|request.*(reject|denied|cancel)/i.test( + readMessage(error), + ); +} + +function isTimeout(error, status) { + const code = error?.code; + return ( + status === 408 || + code === 'ETIMEDOUT' || + code === 'ECONNABORTED' || + error?.name === 'AbortError' || + /timeout|timed out/i.test(readMessage(error)) + ); +} + +export function normalizeError(error, { source = 'api' } = {}) { + const status = readStatus(error); + let code = ERROR_CODES.UNKNOWN; + let retryable = false; + + if (source === 'wallet' && isWalletRejected(error)) { + code = ERROR_CODES.WALLET_REJECTED; + } else if (isTimeout(error, status)) { + code = ERROR_CODES.TIMEOUT; + retryable = true; + } else if (status === 429) { + code = ERROR_CODES.RATE_LIMITED; + retryable = true; + } else if ( + status === 425 || + (status !== null && status >= 500) || + ['ECONNRESET', 'ENETUNREACH', 'EAI_AGAIN'].includes(error?.code) + ) { + code = ERROR_CODES.UNAVAILABLE; + retryable = true; + } + + return Object.freeze({ + code, + retryable, + correlationId: readCorrelationId(error), + }); +} + +export function getUserErrorMessage(normalizedError) { + return SAFE_MESSAGES[normalizedError?.code] ?? SAFE_MESSAGES[ERROR_CODES.UNKNOWN]; +} + +export function canRetry(normalizedError) { + return normalizedError?.retryable === true; +} From fbf693a90534f1ffb02865dac544b07af2641b8c Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 19:51:24 +1000 Subject: [PATCH 3/9] fix: normalize transfer errors and retry policy --- src/hooks/useTransfers.js | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js index fa9d448..e4f7eec 100644 --- a/src/hooks/useTransfers.js +++ b/src/hooks/useTransfers.js @@ -1,24 +1,30 @@ + import { useCallback, useEffect, useState } from 'react'; import { listTransfers, createTransfer } from '../services/api.js'; +import { getUserErrorMessage, normalizeError } from '../services/errors.js'; /** * Hook for loading and creating transfers. * @returns {{transfers: Array, loading: boolean, error: string|null, - * reload: Function, addTransfer: Function}} + * retryable: boolean, reload: Function|undefined, addTransfer: Function}} */ export function useTransfers() { const [transfers, setTransfers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [retryable, setRetryable] = useState(false); const reload = useCallback(async () => { setLoading(true); setError(null); + setRetryable(false); try { const data = await listTransfers(); setTransfers(data); - } catch { - setError('Could not load transfers. Please try again.'); + } catch (err) { + const normalized = normalizeError(err, { source: 'api' }); + setError(getUserErrorMessage(normalized)); + setRetryable(normalized.retryable); } finally { setLoading(false); } @@ -34,5 +40,16 @@ export function useTransfers() { return created; }, []); - return { transfers, loading, error, reload, addTransfer }; + // Existing consumers use reload for both pull-to-refresh and the error-state + // retry action. Withhold it only while a non-retryable error is displayed. + const safeReload = error && !retryable ? undefined : reload; + + return { + transfers, + loading, + error, + retryable, + reload: safeReload, + addTransfer, + }; } From 740576337d4e238112e98bbe2b7d0a0a9253e233 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 19:52:47 +1000 Subject: [PATCH 4/9] fix: normalize transfer submit errors --- src/pages/SendMoney.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index 344b26c..5a23ddf 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -1,3 +1,4 @@ + import { useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import TextField from '../components/TextField.jsx'; @@ -6,6 +7,7 @@ import QuoteCard from '../components/QuoteCard.jsx'; import Button from '../components/Button.jsx'; import ErrorMessage from '../components/ErrorMessage.jsx'; import { buildQuote } from '../services/quote.js'; +import { getUserErrorMessage, normalizeError } from '../services/errors.js'; import { formatCurrencyInput } from '../utils/format.js'; import { isPositiveAmount, @@ -111,7 +113,8 @@ export default function SendMoney() { }); navigate('/transfers'); } catch (err) { - setSubmitError('Could not submit the transfer. Please try again.'); + const normalized = normalizeError(err, { source: 'api' }); + setSubmitError(getUserErrorMessage(normalized)); } finally { submissionLock.current = false; setSubmitting(false); From bf4869ce0d5c816116bf2472dbf0c5943c991676 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 19:54:09 +1000 Subject: [PATCH 5/9] test: assert safe wallet error messages --- test/unit/AppContext.wallet.test.jsx | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/test/unit/AppContext.wallet.test.jsx b/test/unit/AppContext.wallet.test.jsx index 5368cb2..20c04bf 100644 --- a/test/unit/AppContext.wallet.test.jsx +++ b/test/unit/AppContext.wallet.test.jsx @@ -1,9 +1,9 @@ + import { describe, expect, it, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { AppProvider, useApp } from '../../src/context/AppContext.jsx'; import * as walletService from '../../src/services/wallet.js'; -// Test component to access context function TestComponent() { const { wallet, @@ -58,15 +58,12 @@ describe('AppContext wallet connection handling', () => { , ); - const connectButton = screen.getByText('Connect'); - connectButton.click(); + screen.getByText('Connect').click(); - // Should show connecting state await waitFor(() => { expect(screen.getByTestId('connecting')).toHaveTextContent('yes'); }); - // Should complete connection await waitFor(() => { expect(screen.getByTestId('connecting')).toHaveTextContent('no'); expect(screen.getByTestId('connected')).toHaveTextContent('yes'); @@ -110,14 +107,14 @@ describe('AppContext wallet connection handling', () => { expect(screen.getByTestId('connecting')).toHaveTextContent('no'); expect(screen.getByTestId('connected')).toHaveTextContent('no'); expect(screen.getByTestId('error')).toHaveTextContent( - 'User rejected the connection request', + 'Wallet connection was cancelled.', ); }); }); it('handles connection timeout', async () => { vi.spyOn(walletService, 'connectWallet').mockImplementation( - () => new Promise(() => {}), // Never resolves + () => new Promise(() => {}), ); render( @@ -130,7 +127,7 @@ describe('AppContext wallet connection handling', () => { await waitFor(() => { expect(screen.getByTestId('error')).toHaveTextContent( - 'Connection timeout', + 'The request timed out. Please try again.', ); }); }); @@ -143,22 +140,20 @@ describe('AppContext wallet connection handling', () => { const mockAccount = { publicKey: 'GTEST789', balance: 750 }; vi.spyOn(walletService, 'getStoredWallet').mockReturnValue(mockAccount); - const { rerender } = render( + render( , ); - // Try to connect and fail screen.getByText('Connect').click(); await waitFor(() => { expect(screen.getByTestId('error')).toHaveTextContent( - 'Connection failed', + 'Something went wrong. Please try again.', ); }); - // Simulate having a connected wallet from previous session vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount); screen.getByText('Connect').click(); @@ -166,7 +161,6 @@ describe('AppContext wallet connection handling', () => { expect(screen.getByTestId('connected')).toHaveTextContent('yes'); }); - // Disconnect should clear error screen.getByText('Disconnect').click(); await waitFor(() => { @@ -186,14 +180,14 @@ describe('AppContext wallet connection handling', () => { , ); - // First attempt fails screen.getByText('Connect').click(); await waitFor(() => { - expect(screen.getByTestId('error')).toHaveTextContent('First error'); + expect(screen.getByTestId('error')).toHaveTextContent( + 'Something went wrong. Please try again.', + ); }); - // Second attempt succeeds screen.getByText('Connect').click(); await waitFor(() => { @@ -229,14 +223,12 @@ describe('AppContext wallet connection handling', () => { , ); - // Connect first screen.getByText('Connect').click(); await waitFor(() => { expect(screen.getByTestId('connected')).toHaveTextContent('yes'); }); - // Then disconnect screen.getByText('Disconnect').click(); await waitFor(() => { From e6d40128dce12d4d3cd7f53bbd1a04d129201f77 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 19:55:20 +1000 Subject: [PATCH 6/9] test: update wallet error feedback assertions --- test/components/WalletButton.test.jsx | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/test/components/WalletButton.test.jsx b/test/components/WalletButton.test.jsx index eb8b004..46287e2 100644 --- a/test/components/WalletButton.test.jsx +++ b/test/components/WalletButton.test.jsx @@ -1,3 +1,4 @@ + import { describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -56,7 +57,7 @@ describe('WalletButton', () => { }); }); - it('displays error alert when connection is rejected', async () => { + it('displays a safe error alert when connection is rejected', async () => { vi.spyOn(walletService, 'connectWallet').mockRejectedValue( new Error('User cancelled connection'), ); @@ -68,19 +69,18 @@ describe('WalletButton', () => { await waitFor(() => { expect( - screen.getByText(/user cancelled connection/i), + screen.getByText(/wallet connection was cancelled/i), ).toBeInTheDocument(); }); - // Button should be enabled again expect( screen.getByRole('button', { name: /connect wallet/i }), ).not.toBeDisabled(); }); - it('displays error alert on connection timeout', async () => { + it('displays a safe error alert on connection timeout', async () => { vi.spyOn(walletService, 'connectWallet').mockImplementation( - () => new Promise(() => {}), // Never resolves + () => new Promise(() => {}), ); renderWithProvider(, 100); @@ -91,13 +91,13 @@ describe('WalletButton', () => { await waitFor( () => { - expect(screen.getByText(/connection timeout/i)).toBeInTheDocument(); + expect(screen.getByText(/the request timed out/i)).toBeInTheDocument(); }, { timeout: 2000 }, ); }); - it('clears error on successful retry after failed connection', async () => { + it('clears safe error on successful retry after failed connection', async () => { const mockAccount = { publicKey: 'GTEST123', balance: 500 }; vi.spyOn(walletService, 'connectWallet') .mockRejectedValueOnce(new Error('Connection failed')) @@ -105,22 +105,20 @@ describe('WalletButton', () => { renderWithProvider(); - // First attempt fails await userEvent.click( screen.getByRole('button', { name: /connect wallet/i }), ); await waitFor(() => { - expect(screen.getByText(/connection failed/i)).toBeInTheDocument(); + expect(screen.getByText(/something went wrong/i)).toBeInTheDocument(); }); - // Second attempt succeeds await userEvent.click( screen.getByRole('button', { name: /connect wallet/i }), ); await waitFor(() => { - expect(screen.queryByText(/connection failed/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument(); expect(screen.getByText(/500 XLM/)).toBeInTheDocument(); }); }); @@ -132,7 +130,6 @@ describe('WalletButton', () => { renderWithProvider(); - // Connect await userEvent.click( screen.getByRole('button', { name: /connect wallet/i }), ); @@ -143,7 +140,6 @@ describe('WalletButton', () => { ).toBeInTheDocument(); }); - // Disconnect await userEvent.click(screen.getByRole('button', { name: /disconnect/i })); await waitFor(() => { @@ -166,7 +162,6 @@ describe('WalletButton', () => { await userEvent.click(button); - // Button should be disabled during connection await waitFor(() => { const connectingButton = screen.getByRole('button', { name: /connecting/i, From dcae069d91fc65ae9c610c21ea731eb1520dbf85 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 19:56:45 +1000 Subject: [PATCH 7/9] test: assert safe submit failure message --- test/integration/send-money-form.test.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/integration/send-money-form.test.jsx b/test/integration/send-money-form.test.jsx index 956eded..80e37cd 100644 --- a/test/integration/send-money-form.test.jsx +++ b/test/integration/send-money-form.test.jsx @@ -1,3 +1,4 @@ + import { act, fireEvent, @@ -127,7 +128,7 @@ describe('Send money form flows', () => { expect(createTransfer).toHaveBeenCalledTimes(1); }); - it('releases the submission lock after failure and permits a retry', async () => { + it('releases the submission lock after a safe failure and permits a retry', async () => { const createTransfer = vi .spyOn(api, 'createTransfer') .mockRejectedValueOnce(new Error('transfer failed')) @@ -138,9 +139,7 @@ describe('Send money form flows', () => { await fillValidForm(user); await user.click(screen.getByRole('button', { name: /review & send/i })); - expect( - await screen.findByText(/could not submit the transfer/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument(); const retryButton = screen.getByRole('button', { name: /review & send/i }); expect(retryButton).toBeEnabled(); From 4e6f2a7b1d98ddb48e5d3b49f680cfc65a776bcc Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 19:57:59 +1000 Subject: [PATCH 8/9] test: cover error normalization and redaction --- test/unit/errors.test.js | 81 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 test/unit/errors.test.js diff --git a/test/unit/errors.test.js b/test/unit/errors.test.js new file mode 100644 index 0000000..ddb8e6f --- /dev/null +++ b/test/unit/errors.test.js @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { + ERROR_CODES, + canRetry, + getUserErrorMessage, + normalizeError, +} from '../../src/services/errors.js'; + +describe('error normalization', () => { + it('drops malformed provider payloads and sensitive values', () => { + const normalized = normalizeError({ + payload: { + credentialLikeValue: 'SENSITIVE_VALUE', + address: 'GFAKEPUBLICADDRESS1234567890', + }, + message: 'provider returned SENSITIVE_VALUE', + }); + + expect(normalized).toEqual({ + code: ERROR_CODES.UNKNOWN, + retryable: false, + correlationId: null, + }); + const serialized = JSON.stringify(normalized); + expect(serialized).not.toContain('SENSITIVE_VALUE'); + expect(serialized).not.toContain('GFAKEPUBLICADDRESS'); + }); + + it('maps wallet cancellation separately and does not retry it', () => { + const normalized = normalizeError( + new Error('User rejected the connection request'), + { source: 'wallet' }, + ); + + expect(normalized.code).toBe(ERROR_CODES.WALLET_REJECTED); + expect(canRetry(normalized)).toBe(false); + expect(getUserErrorMessage(normalized)).toBe( + 'Wallet connection was cancelled.', + ); + }); + + it('marks timeouts, rate limits, and server failures retryable', () => { + expect(normalizeError(new Error('Connection timeout'))).toMatchObject({ + code: ERROR_CODES.TIMEOUT, + retryable: true, + }); + expect(normalizeError({ status: 429 })).toMatchObject({ + code: ERROR_CODES.RATE_LIMITED, + retryable: true, + }); + expect(normalizeError({ response: { status: 503 } })).toMatchObject({ + code: ERROR_CODES.UNAVAILABLE, + retryable: true, + }); + }); + + it('retains only a validated correlation identifier', () => { + const normalized = normalizeError({ + status: 503, + correlationId: 'req_abc-123:west', + message: 'provider returned PRIVATE_VALUE', + response: { data: { detail: 'RAW_PROVIDER_DATA' } }, + }); + + expect(normalized).toEqual({ + code: ERROR_CODES.UNAVAILABLE, + retryable: true, + correlationId: 'req_abc-123:west', + }); + const serialized = JSON.stringify(normalized); + expect(serialized).not.toContain('PRIVATE_VALUE'); + expect(serialized).not.toContain('RAW_PROVIDER_DATA'); + }); + + it('drops unsafe correlation identifiers', () => { + expect( + normalizeError({ correlationId: 'unsafe correlation value' }) + .correlationId, + ).toBeNull(); + }); +}); From 9c06486deef9a0266378d1a1a7f20e402deea5b1 Mon Sep 17 00:00:00 2001 From: isaacsamual994-lang Date: Sat, 22 Aug 2026 20:01:45 +1000 Subject: [PATCH 9/9] test: cover transfer retry policy --- test/unit/useTransfers.errors.test.jsx | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 test/unit/useTransfers.errors.test.jsx diff --git a/test/unit/useTransfers.errors.test.jsx b/test/unit/useTransfers.errors.test.jsx new file mode 100644 index 0000000..071426f --- /dev/null +++ b/test/unit/useTransfers.errors.test.jsx @@ -0,0 +1,38 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useTransfers } from '../../src/hooks/useTransfers.js'; +import * as api from '../../src/services/api.js'; + +describe('useTransfers error retry policy', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('withholds reload while a non-retryable error is displayed', async () => { + vi.spyOn(api, 'listTransfers').mockRejectedValue({ status: 400 }); + + const { result } = renderHook(() => useTransfers()); + + await waitFor(() => { + expect(result.current.error).toBe('Something went wrong. Please try again.'); + }); + + expect(result.current.retryable).toBe(false); + expect(result.current.reload).toBeUndefined(); + }); + + it('keeps reload available for a retryable service failure', async () => { + vi.spyOn(api, 'listTransfers').mockRejectedValue({ status: 503 }); + + const { result } = renderHook(() => useTransfers()); + + await waitFor(() => { + expect(result.current.error).toBe( + 'The service is temporarily unavailable. Please try again.', + ); + }); + + expect(result.current.retryable).toBe(true); + expect(result.current.reload).toEqual(expect.any(Function)); + }); +});