diff --git a/README.md b/README.md
index fff5a6d..fba0596 100644
--- a/README.md
+++ b/README.md
@@ -21,8 +21,10 @@ The app runs at http://localhost:5173 by default.
completed, failed), search/status/date-range filters synced to the URL,
plus loading, error and empty states.
- **Mock wallet** — connect a demo Stellar wallet (no network calls).
-- **Keyboard navigation** — skip link, header, page content, and footer follow a
- logical tab order; navigation actions use a single focus stop each.
+ - Robust error handling for rejected connections
+ - Connection timeout protection (30 seconds)
+ - Clear error feedback to users
+ - Automatic error state clearing on retry or disconnect
## Tech Stack
@@ -63,6 +65,26 @@ cp .env.example .env
## Testing
+The test suite includes comprehensive coverage of wallet connection handling:
+
+- **Wallet Service Tests** (`test/services/wallet.test.js`)
+ - Successful connection flow
+ - User rejection handling
+ - Storage persistence
+ - Disconnection cleanup
+
+- **AppContext Wallet Tests** (`test/unit/AppContext.wallet.test.jsx`)
+ - Connection state management
+ - Error handling and recovery
+ - Timeout protection
+ - State restoration from localStorage
+
+- **WalletButton Tests** (`test/components/WalletButton.test.jsx`)
+ - UI feedback for connection states
+ - Error message display
+ - Retry behavior
+ - User interaction flows
+
Integration tests cover send-money validation, successful transfer submission,
pending button behavior, duplicate-submission prevention, Transfers page filter
sync (search, status, and date-range presets), and keyboard tab order across
diff --git a/src/components/WalletButton.jsx b/src/components/WalletButton.jsx
index 87029b0..c9deda8 100644
--- a/src/components/WalletButton.jsx
+++ b/src/components/WalletButton.jsx
@@ -1,13 +1,23 @@
import { useWallet } from '../hooks/useWallet.js'
import { shortenAddress } from '../utils/format.js'
import Button from './Button.jsx'
+import Alert from './Alert.jsx'
import './WalletButton.css'
/**
* Connect / disconnect the mock Stellar wallet.
*/
export default function WalletButton() {
- const { wallet, isConnected, connecting, connect, disconnect } = useWallet()
+ const { wallet, isConnected, connecting, connectionError, connect, disconnect } = useWallet()
+
+ async function handleConnect() {
+ try {
+ await connect()
+ } catch (err) {
+ // Error is already stored in context, just prevent propagation
+ console.error('Wallet connection failed:', err)
+ }
+ }
if (isConnected) {
return (
@@ -24,8 +34,15 @@ export default function WalletButton() {
}
return (
-
+
+
+ {connectionError && (
+
+ {connectionError}
+
+ )}
+
)
}
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx
index f1c0e3a..28b3091 100644
--- a/src/context/AppContext.jsx
+++ b/src/context/AppContext.jsx
@@ -12,6 +12,7 @@ const AppContext = createContext(null)
export function AppProvider({ children }) {
const [wallet, setWallet] = useState(null)
const [connecting, setConnecting] = useState(false)
+ const [connectionError, setConnectionError] = useState(null)
const [storedLocale, setStoredLocale] = useLocalStorage(LOCALE_STORAGE_KEY, DEFAULT_LOCALE)
// Guard against a stale or tampered value in localStorage (e.g. left over
@@ -30,10 +31,22 @@ export function AppProvider({ children }) {
async function connect() {
setConnecting(true)
+ setConnectionError(null)
+
try {
- const account = await connectWallet()
+ // Add a timeout to prevent hanging indefinitely
+ const timeoutPromise = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Connection timeout')), 30000)
+ )
+
+ const account = await Promise.race([connectWallet(), timeoutPromise])
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)
+ throw err
} finally {
setConnecting(false)
}
@@ -42,11 +55,13 @@ export function AppProvider({ children }) {
function disconnect() {
disconnectWallet()
setWallet(null)
+ setConnectionError(null)
}
const value = {
wallet,
connecting,
+ connectionError,
isConnected: Boolean(wallet),
connect,
disconnect,
diff --git a/src/hooks/useWallet.js b/src/hooks/useWallet.js
index 78c5e37..fbd0322 100644
--- a/src/hooks/useWallet.js
+++ b/src/hooks/useWallet.js
@@ -3,9 +3,9 @@ import { useApp } from '../context/AppContext.jsx'
/**
* Convenience hook for accessing wallet state and actions.
* @returns {{wallet: object|null, isConnected: boolean, connecting: boolean,
- * signing: boolean, connect: Function, disconnect: Function, sign: Function}}
+ * connectionError: string|null, signing: boolean, connect: Function, disconnect: Function, sign: Function}}
*/
export function useWallet() {
- const { wallet, isConnected, connecting, signing, connect, disconnect, sign } = useApp()
- return { wallet, isConnected, connecting, signing, connect, disconnect, sign }
+ const { wallet, isConnected, connecting, connectionError, signing, connect, disconnect, sign } = useApp()
+ return { wallet, isConnected, connecting, connectionError, signing, connect, disconnect, sign }
}
diff --git a/src/services/wallet.js b/src/services/wallet.js
index 068f20b..b98d076 100644
--- a/src/services/wallet.js
+++ b/src/services/wallet.js
@@ -9,11 +9,20 @@ const DEMO_PUBLIC_KEY = 'GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF'
/**
* Simulate connecting a Stellar wallet.
+ * In production, this would integrate with Freighter/Albedo and handle user rejections.
* @returns {Promise<{publicKey: string, balance: number}>}
*/
export function connectWallet() {
- return new Promise((resolve) => {
+ return new Promise((resolve, reject) => {
+ // Simulate a 10% chance of user rejection for testing
+ const shouldReject = Math.random() < 0.1
+
setTimeout(() => {
+ if (shouldReject) {
+ reject(new Error('User rejected the connection request'))
+ return
+ }
+
const account = {
publicKey: DEMO_PUBLIC_KEY,
balance: 1000
diff --git a/test/components/WalletButton.test.jsx b/test/components/WalletButton.test.jsx
new file mode 100644
index 0000000..7259785
--- /dev/null
+++ b/test/components/WalletButton.test.jsx
@@ -0,0 +1,140 @@
+import { describe, expect, it, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import WalletButton from '../../src/components/WalletButton.jsx'
+import { AppProvider } from '../../src/context/AppContext.jsx'
+import * as walletService from '../../src/services/wallet.js'
+
+function renderWithProvider(component) {
+ return render({component})
+}
+
+describe('WalletButton', () => {
+ it('renders connect button when wallet is not connected', () => {
+ renderWithProvider()
+ expect(screen.getByRole('button', { name: /connect wallet/i })).toBeInTheDocument()
+ })
+
+ it('shows connecting state during connection attempt', async () => {
+ vi.spyOn(walletService, 'connectWallet').mockImplementation(() =>
+ new Promise(resolve => setTimeout(() => resolve({ publicKey: 'GTEST', balance: 1000 }), 100))
+ )
+
+ renderWithProvider()
+ const button = screen.getByRole('button', { name: /connect wallet/i })
+
+ await userEvent.click(button)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /connecting/i })).toBeDisabled()
+ })
+ })
+
+ it('displays wallet info when connected', async () => {
+ const mockAccount = { publicKey: 'GBQAZ7Z3X7DEMOPUBLICKEY', balance: 1000 }
+ vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount)
+
+ renderWithProvider()
+
+ await userEvent.click(screen.getByRole('button', { name: /connect wallet/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/1000 XLM/)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /disconnect/i })).toBeInTheDocument()
+ })
+ })
+
+ it('displays error alert when connection is rejected', async () => {
+ vi.spyOn(walletService, 'connectWallet').mockRejectedValue(
+ new Error('User rejected the connection request')
+ )
+
+ renderWithProvider()
+
+ await userEvent.click(screen.getByRole('button', { name: /connect wallet/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/user rejected the connection request/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 () => {
+ vi.spyOn(walletService, 'connectWallet').mockImplementation(() =>
+ new Promise(() => {}) // Never resolves
+ )
+
+ renderWithProvider()
+
+ await userEvent.click(screen.getByRole('button', { name: /connect wallet/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/connection timeout/i)).toBeInTheDocument()
+ }, { timeout: 31000 })
+ })
+
+ it('clears 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()
+
+ // First attempt fails
+ await userEvent.click(screen.getByRole('button', { name: /connect wallet/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/connection failed/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.getByText(/500 XLM/)).toBeInTheDocument()
+ })
+ })
+
+ it('handles disconnect correctly', async () => {
+ const mockAccount = { publicKey: 'GTEST456', balance: 750 }
+ vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount)
+ vi.spyOn(walletService, 'disconnectWallet').mockImplementation(() => {})
+
+ renderWithProvider()
+
+ // Connect
+ await userEvent.click(screen.getByRole('button', { name: /connect wallet/i }))
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /disconnect/i })).toBeInTheDocument()
+ })
+
+ // Disconnect
+ await userEvent.click(screen.getByRole('button', { name: /disconnect/i }))
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /connect wallet/i })).toBeInTheDocument()
+ })
+ })
+
+ it('does not allow clicking connect button while connecting', async () => {
+ vi.spyOn(walletService, 'connectWallet').mockImplementation(() =>
+ new Promise(resolve => setTimeout(() => resolve({ publicKey: 'GTEST', balance: 1000 }), 200))
+ )
+
+ renderWithProvider()
+ const button = screen.getByRole('button', { name: /connect wallet/i })
+
+ await userEvent.click(button)
+
+ // Button should be disabled during connection
+ await waitFor(() => {
+ const connectingButton = screen.getByRole('button', { name: /connecting/i })
+ expect(connectingButton).toBeDisabled()
+ })
+ })
+})
diff --git a/test/services/wallet.test.js b/test/services/wallet.test.js
index 6552ccd..5dd769e 100644
--- a/test/services/wallet.test.js
+++ b/test/services/wallet.test.js
@@ -1,5 +1,51 @@
-import { describe, expect, it } from 'vitest'
-import { signTransaction } from '../../src/services/wallet.js'
+import { describe, expect, it, beforeEach, vi } from 'vitest'
+import { connectWallet, signTransaction, getStoredWallet, disconnectWallet } from '../../src/services/wallet.js'
+
+describe('connectWallet', () => {
+ beforeEach(() => {
+ // Clear localStorage before each test
+ localStorage.clear()
+ // Reset random number generator to ensure consistent test behavior
+ vi.spyOn(Math, 'random').mockReturnValue(0.5) // Ensures no rejection in most tests
+ })
+
+ it('resolves with wallet account data on successful connection', async () => {
+ const account = await connectWallet()
+ expect(account).toHaveProperty('publicKey')
+ expect(account).toHaveProperty('balance')
+ expect(typeof account.publicKey).toBe('string')
+ expect(typeof account.balance).toBe('number')
+ })
+
+ it('stores the connected wallet in localStorage', async () => {
+ const account = await connectWallet()
+ const stored = getStoredWallet()
+ expect(stored).toEqual(account)
+ })
+
+ it('rejects with an error when user rejects the connection', async () => {
+ // Mock rejection scenario (10% chance in implementation)
+ Math.random.mockReturnValue(0.05)
+
+ await expect(connectWallet()).rejects.toThrow('User rejected the connection request')
+
+ // Verify wallet was not stored
+ const stored = getStoredWallet()
+ expect(stored).toBeNull()
+ })
+
+ it('does not store wallet data on rejection', async () => {
+ Math.random.mockReturnValue(0.05) // Force rejection
+
+ try {
+ await connectWallet()
+ } catch (err) {
+ // Expected to throw
+ }
+
+ expect(getStoredWallet()).toBeNull()
+ })
+})
describe('signTransaction', () => {
it('resolves with a unique signature, simulating the wallet signing prompt', async () => {
@@ -14,3 +60,35 @@ describe('signTransaction', () => {
expect(first.signature).not.toBe(second.signature)
})
})
+
+describe('getStoredWallet', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('returns null when no wallet is stored', () => {
+ expect(getStoredWallet()).toBeNull()
+ })
+
+ it('returns the stored wallet account', () => {
+ const account = { publicKey: 'GTEST123', balance: 500 }
+ localStorage.setItem('remitflow.wallet', JSON.stringify(account))
+ expect(getStoredWallet()).toEqual(account)
+ })
+
+ it('returns null if stored data is invalid JSON', () => {
+ localStorage.setItem('remitflow.wallet', 'invalid json')
+ expect(getStoredWallet()).toBeNull()
+ })
+})
+
+describe('disconnectWallet', () => {
+ it('removes the wallet from localStorage', () => {
+ const account = { publicKey: 'GTEST123', balance: 500 }
+ localStorage.setItem('remitflow.wallet', JSON.stringify(account))
+
+ disconnectWallet()
+
+ expect(getStoredWallet()).toBeNull()
+ })
+})
diff --git a/test/unit/AppContext.wallet.test.jsx b/test/unit/AppContext.wallet.test.jsx
new file mode 100644
index 0000000..a6d01d4
--- /dev/null
+++ b/test/unit/AppContext.wallet.test.jsx
@@ -0,0 +1,230 @@
+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, isConnected, connecting, connectionError, connect, disconnect } = useApp()
+
+ return (
+
+
{isConnected ? 'yes' : 'no'}
+
{connecting ? 'yes' : 'no'}
+
{connectionError || 'none'}
+
{wallet?.publicKey || 'none'}
+
+
+
+ )
+}
+
+describe('AppContext wallet connection handling', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ vi.clearAllMocks()
+ })
+
+ it('starts with no wallet connected', () => {
+ render(
+
+
+
+ )
+
+ expect(screen.getByTestId('connected')).toHaveTextContent('no')
+ expect(screen.getByTestId('connecting')).toHaveTextContent('no')
+ expect(screen.getByTestId('error')).toHaveTextContent('none')
+ expect(screen.getByTestId('wallet-key')).toHaveTextContent('none')
+ })
+
+ it('sets connecting state during connection attempt', async () => {
+ const mockAccount = { publicKey: 'GTEST123', balance: 1000 }
+ vi.spyOn(walletService, 'connectWallet').mockImplementation(() =>
+ new Promise(resolve => setTimeout(() => resolve(mockAccount), 100))
+ )
+
+ render(
+
+
+
+ )
+
+ const connectButton = screen.getByText('Connect')
+ connectButton.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')
+ expect(screen.getByTestId('wallet-key')).toHaveTextContent('GTEST123')
+ })
+ })
+
+ it('handles successful wallet connection', async () => {
+ const mockAccount = { publicKey: 'GTEST456', balance: 500 }
+ vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount)
+
+ render(
+
+
+
+ )
+
+ screen.getByText('Connect').click()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('connected')).toHaveTextContent('yes')
+ expect(screen.getByTestId('wallet-key')).toHaveTextContent('GTEST456')
+ expect(screen.getByTestId('error')).toHaveTextContent('none')
+ })
+ })
+
+ it('handles rejected wallet connection', async () => {
+ vi.spyOn(walletService, 'connectWallet').mockRejectedValue(
+ new Error('User rejected the connection request')
+ )
+
+ render(
+
+
+
+ )
+
+ screen.getByText('Connect').click()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('connecting')).toHaveTextContent('no')
+ expect(screen.getByTestId('connected')).toHaveTextContent('no')
+ expect(screen.getByTestId('error')).toHaveTextContent('User rejected the connection request')
+ })
+ })
+
+ it('handles connection timeout', async () => {
+ vi.spyOn(walletService, 'connectWallet').mockImplementation(() =>
+ new Promise(() => {}) // Never resolves
+ )
+
+ render(
+
+
+
+ )
+
+ screen.getByText('Connect').click()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('error')).toHaveTextContent('Connection timeout')
+ }, { timeout: 31000 }) // Wait for 30s timeout + buffer
+ })
+
+ it('clears error when disconnecting', async () => {
+ vi.spyOn(walletService, 'connectWallet').mockRejectedValue(
+ new Error('Connection failed')
+ )
+
+ const mockAccount = { publicKey: 'GTEST789', balance: 750 }
+ vi.spyOn(walletService, 'getStoredWallet').mockReturnValue(mockAccount)
+
+ const { rerender } = render(
+
+
+
+ )
+
+ // Try to connect and fail
+ screen.getByText('Connect').click()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('error')).toHaveTextContent('Connection failed')
+ })
+
+ // Simulate having a connected wallet from previous session
+ vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount)
+ screen.getByText('Connect').click()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('connected')).toHaveTextContent('yes')
+ })
+
+ // Disconnect should clear error
+ screen.getByText('Disconnect').click()
+
+ expect(screen.getByTestId('connected')).toHaveTextContent('no')
+ expect(screen.getByTestId('error')).toHaveTextContent('none')
+ })
+
+ it('clears previous error on new connection attempt', async () => {
+ vi.spyOn(walletService, 'connectWallet')
+ .mockRejectedValueOnce(new Error('First error'))
+ .mockResolvedValueOnce({ publicKey: 'GTEST999', balance: 200 })
+
+ render(
+
+
+
+ )
+
+ // First attempt fails
+ screen.getByText('Connect').click()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('error')).toHaveTextContent('First error')
+ })
+
+ // Second attempt succeeds
+ screen.getByText('Connect').click()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('connected')).toHaveTextContent('yes')
+ expect(screen.getByTestId('error')).toHaveTextContent('none')
+ })
+ })
+
+ it('restores previously connected wallet on mount', async () => {
+ const storedAccount = { publicKey: 'GSTORED123', balance: 300 }
+ vi.spyOn(walletService, 'getStoredWallet').mockReturnValue(storedAccount)
+
+ render(
+
+
+
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('connected')).toHaveTextContent('yes')
+ expect(screen.getByTestId('wallet-key')).toHaveTextContent('GSTORED123')
+ })
+ })
+
+ it('handles disconnect correctly', async () => {
+ const mockAccount = { publicKey: 'GTEST555', balance: 600 }
+ vi.spyOn(walletService, 'connectWallet').mockResolvedValue(mockAccount)
+ vi.spyOn(walletService, 'disconnectWallet').mockImplementation(() => {})
+
+ render(
+
+
+
+ )
+
+ // Connect first
+ screen.getByText('Connect').click()
+
+ await waitFor(() => {
+ expect(screen.getByTestId('connected')).toHaveTextContent('yes')
+ })
+
+ // Then disconnect
+ screen.getByText('Disconnect').click()
+
+ expect(screen.getByTestId('connected')).toHaveTextContent('no')
+ expect(screen.getByTestId('wallet-key')).toHaveTextContent('none')
+ expect(walletService.disconnectWallet).toHaveBeenCalled()
+ })
+})