Skip to content
7 changes: 4 additions & 3 deletions src/context/AppContext.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
25 changes: 21 additions & 4 deletions src/hooks/useTransfers.js
Original file line number Diff line number Diff line change
@@ -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);
}
Expand All @@ -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,
};
}
5 changes: 4 additions & 1 deletion src/pages/SendMoney.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

import { useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import TextField from '../components/TextField.jsx';
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
94 changes: 94 additions & 0 deletions src/services/errors.js
Original file line number Diff line number Diff line change
@@ -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;
}
23 changes: 9 additions & 14 deletions test/components/WalletButton.test.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'),
);
Expand All @@ -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(<WalletButton />, 100);
Expand All @@ -91,36 +91,34 @@ 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'))
.mockResolvedValueOnce(mockAccount);

renderWithProvider(<WalletButton />);

// 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();
});
});
Expand All @@ -132,7 +130,6 @@ describe('WalletButton', () => {

renderWithProvider(<WalletButton />);

// Connect
await userEvent.click(
screen.getByRole('button', { name: /connect wallet/i }),
);
Expand All @@ -143,7 +140,6 @@ describe('WalletButton', () => {
).toBeInTheDocument();
});

// Disconnect
await userEvent.click(screen.getByRole('button', { name: /disconnect/i }));

await waitFor(() => {
Expand All @@ -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,
Expand Down
7 changes: 3 additions & 4 deletions test/integration/send-money-form.test.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

import {
act,
fireEvent,
Expand Down Expand Up @@ -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'))
Expand All @@ -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();

Expand Down
Loading