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);
}
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,
+ };
}
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);
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;
+}
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,
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();
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(() => {
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();
+ });
+});
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));
+ });
+});