Skip to content

Commit 5b66e25

Browse files
authored
Merge pull request #548 from UgoxyFavour/main
I implemented the add wallet connection timeout logic
2 parents cd70bbc + 5f094ed commit 5b66e25

3 files changed

Lines changed: 238 additions & 15 deletions

File tree

frontend/src/data/documentation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ If issues persist, check the transaction simulation panel for specific error det
335335
- Check that you're logged into Freighter
336336
- Try refreshing the page and reconnecting
337337
- Verify you're on the correct network (Mainnet/Testnet)
338+
- If the approval prompt does not appear within 15 seconds, PayD will show a timeout error so you can retry safely
338339
339340
**xBull:**
340341
- Make sure xBull mobile app is installed
@@ -344,6 +345,7 @@ If issues persist, check the transaction simulation panel for specific error det
344345
**Lobstr:**
345346
- Verify Lobstr extension/app is active
346347
- Ensure you've approved the connection request
348+
- If the approval prompt stalls for more than 15 seconds, close the request, reopen the wallet, and try again
347349
348350
**General fixes:**
349351
- Clear browser cache and cookies

frontend/src/providers/WalletProvider.tsx

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { WalletContext } from '../hooks/useWallet';
1414

1515
const LAST_WALLET_STORAGE_KEY = 'payd:last_wallet_name';
1616
const SUPPORTED_MODAL_WALLETS = [FREIGHTER_ID, LOBSTR_ID] as const;
17+
const WALLET_CONNECTION_TIMEOUT_MS = 15000;
18+
const WALLET_CONNECTION_TIMEOUT_MESSAGE =
19+
'Wallet connection timed out after 15 seconds. Confirm the request in your wallet and try again.';
1720

1821
type SelectableWallet = {
1922
id: string;
@@ -22,6 +25,25 @@ type SelectableWallet = {
2225
isAvailable: boolean;
2326
};
2427

28+
function withWalletConnectionTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
29+
return new Promise<T>((resolve, reject) => {
30+
const timeoutId = window.setTimeout(() => {
31+
reject(new Error(WALLET_CONNECTION_TIMEOUT_MESSAGE));
32+
}, timeoutMs);
33+
34+
promise.then(
35+
(value) => {
36+
window.clearTimeout(timeoutId);
37+
resolve(value);
38+
},
39+
(error: unknown) => {
40+
window.clearTimeout(timeoutId);
41+
reject(error instanceof Error ? error : new Error(String(error)));
42+
}
43+
);
44+
});
45+
}
46+
2547
function hasAnyWalletExtension(): boolean {
2648
if (typeof window === 'undefined') return true;
2749
const extendedWindow = window as Window &
@@ -34,7 +56,10 @@ function hasAnyWalletExtension(): boolean {
3456
return Boolean(extendedWindow.freighterApi || extendedWindow.xBullSDK || extendedWindow.lobstr);
3557
}
3658

37-
export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
59+
export const WalletProvider: React.FC<{
60+
children: React.ReactNode;
61+
connectionTimeoutMs?: number;
62+
}> = ({ children, connectionTimeoutMs = WALLET_CONNECTION_TIMEOUT_MS }) => {
3863
const [address, setAddress] = useState<string | null>(null);
3964
const [walletName, setWalletName] = useState<string | null>(null);
4065
const [isConnecting, setIsConnecting] = useState(false);
@@ -43,6 +68,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
4368
const [network, setNetwork] = useState<'TESTNET' | 'PUBLIC'>('TESTNET');
4469
const [walletModalOpen, setWalletModalOpen] = useState(false);
4570
const [walletOptions, setWalletOptions] = useState<SelectableWallet[]>([]);
71+
const [connectionError, setConnectionError] = useState<string | null>(null);
4672
const kitRef = useRef<StellarWalletsKit | null>(null);
4773
const { t } = useTranslation();
4874
const { notifyWalletEvent } = useNotification();
@@ -68,7 +94,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
6894

6995
try {
7096
newKit.setWallet(lastWalletName);
71-
const account = await newKit.getAddress();
97+
const account = await withWalletConnectionTimeout(newKit.getAddress(), connectionTimeoutMs);
7298
if (account?.address) {
7399
setAddress(account.address);
74100
notifyWalletEvent(
@@ -85,7 +111,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
85111
};
86112

87113
void attemptSilentReconnect();
88-
}, [notifyWalletEvent, network]);
114+
}, [connectionTimeoutMs, notifyWalletEvent, network]);
89115

90116
const loadWalletOptions = async (): Promise<SelectableWallet[]> => {
91117
const kit = kitRef.current;
@@ -110,35 +136,34 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
110136
const kit = kitRef.current;
111137
if (!kit) return null;
112138

139+
setConnectionError(null);
113140
setIsConnecting(true);
114141
try {
115142
kit.setWallet(selectedWalletId);
116-
117-
const { address } = await Promise.race([
118-
kit.getAddress(),
119-
new Promise<{ address: string }>((_, reject) =>
120-
setTimeout(() => reject(new Error('Connection timed out after 15 seconds.')), 15000)
121-
),
122-
]);
143+
const { address } = await withWalletConnectionTimeout(kit.getAddress(), connectionTimeoutMs);
123144

124145
setAddress(address);
125146
setWalletName(selectedWalletId);
126147
localStorage.setItem(LAST_WALLET_STORAGE_KEY, selectedWalletId);
148+
setConnectionError(null);
149+
setWalletModalOpen(false);
127150
notifyWalletEvent(
128151
'connected',
129152
`${address.slice(0, 6)}...${address.slice(-4)} via ${selectedWalletId}`
130153
);
131154
return address;
132155
} catch (error) {
133156
console.error('Failed to connect wallet:', error);
157+
const message =
158+
error instanceof Error ? error.message : 'Unable to connect to the selected wallet.';
159+
setConnectionError(message);
134160
notifyWalletEvent(
135161
'connection_failed',
136-
error instanceof Error ? error.message : 'Please try again.'
162+
message
137163
);
138164
return null;
139165
} finally {
140166
setIsConnecting(false);
141-
setWalletModalOpen(false);
142167
}
143168
};
144169

@@ -148,6 +173,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
148173
notifyWalletEvent('connection_failed', 'No supported wallet providers were found.');
149174
return null;
150175
}
176+
setConnectionError(null);
151177
setWalletModalOpen(true);
152178
return null;
153179
};
@@ -186,20 +212,43 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
186212

187213
{walletModalOpen && (
188214
<div className="fixed inset-0 z-[80] grid place-items-center bg-black/70 px-4">
189-
<div className="w-full max-w-md rounded-2xl border border-hi bg-surface p-6 shadow-2xl">
215+
<div
216+
className="w-full max-w-md rounded-2xl border border-hi bg-surface p-6 shadow-2xl"
217+
role="dialog"
218+
aria-modal="true"
219+
aria-labelledby="wallet-modal-title"
220+
aria-describedby={connectionError ? 'wallet-connection-error' : undefined}
221+
>
190222
<div className="mb-4 flex items-center justify-between">
191-
<h3 className="text-lg font-black">{t('wallet.modalTitle') || 'Select a wallet'}</h3>
223+
<h3 id="wallet-modal-title" className="text-lg font-black">
224+
{t('wallet.modalTitle') || 'Select a wallet'}
225+
</h3>
192226
<button
193-
onClick={() => setWalletModalOpen(false)}
227+
type="button"
228+
onClick={() => {
229+
setWalletModalOpen(false);
230+
setConnectionError(null);
231+
}}
194232
className="rounded-lg border border-hi px-2 py-1 text-xs text-muted hover:text-text"
195233
>
196234
Close
197235
</button>
198236
</div>
237+
{connectionError && (
238+
<div
239+
id="wallet-connection-error"
240+
role="alert"
241+
aria-live="assertive"
242+
className="mb-4 rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200"
243+
>
244+
{connectionError}
245+
</div>
246+
)}
199247
<div className="space-y-2">
200248
{walletOptions.map((wallet) => (
201249
<button
202250
key={wallet.id}
251+
type="button"
203252
onClick={() => void connectWithWallet(wallet.id)}
204253
disabled={!wallet.isAvailable || isConnecting}
205254
className="flex w-full items-center justify-between rounded-xl border border-hi bg-black/20 px-4 py-3 text-left transition hover:bg-black/30 disabled:cursor-not-allowed disabled:opacity-50"
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import React from 'react';
2+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { WalletProvider } from '../WalletProvider';
5+
import { useWallet } from '../../hooks/useWallet';
6+
7+
const mockNotifyWalletEvent = vi.fn();
8+
const mockGetSupportedWallets = vi.fn();
9+
const mockGetAddress = vi.fn();
10+
const mockSetWallet = vi.fn();
11+
const mockDisconnect = vi.fn();
12+
const mockSignTransaction = vi.fn();
13+
14+
vi.mock('react-i18next', () => ({
15+
useTranslation: () => ({
16+
t: (key: string) => {
17+
if (key === 'wallet.modalTitle') return 'Connect to PayD';
18+
return key;
19+
},
20+
}),
21+
}));
22+
23+
vi.mock('../../hooks/useNotification', () => ({
24+
useNotification: () => ({
25+
notifyWalletEvent: mockNotifyWalletEvent,
26+
}),
27+
}));
28+
29+
vi.mock('@creit.tech/stellar-wallets-kit', () => ({
30+
StellarWalletsKit: class {
31+
setWallet = mockSetWallet;
32+
getAddress = mockGetAddress;
33+
getSupportedWallets = mockGetSupportedWallets;
34+
disconnect = mockDisconnect;
35+
signTransaction = mockSignTransaction;
36+
},
37+
WalletNetwork: {
38+
TESTNET: 'TESTNET',
39+
PUBLIC: 'PUBLIC',
40+
},
41+
FreighterModule: class {},
42+
xBullModule: class {},
43+
LobstrModule: class {},
44+
FREIGHTER_ID: 'freighter',
45+
LOBSTR_ID: 'lobstr',
46+
}));
47+
48+
function createDeferredPromise<T>() {
49+
let resolve!: (value: T) => void;
50+
let reject!: (reason?: unknown) => void;
51+
const promise = new Promise<T>((res, rej) => {
52+
resolve = res;
53+
reject = rej;
54+
});
55+
56+
return { promise, resolve, reject };
57+
}
58+
59+
function WalletHarness() {
60+
const { address, connect } = useWallet();
61+
62+
return (
63+
<div>
64+
<button type="button" onClick={() => void connect()}>
65+
Open wallet modal
66+
</button>
67+
<div>{address ?? 'No wallet connected'}</div>
68+
</div>
69+
);
70+
}
71+
72+
describe('WalletProvider', () => {
73+
beforeEach(() => {
74+
localStorage.clear();
75+
mockNotifyWalletEvent.mockReset();
76+
mockGetSupportedWallets.mockReset();
77+
mockGetAddress.mockReset();
78+
mockSetWallet.mockReset();
79+
mockDisconnect.mockReset();
80+
mockSignTransaction.mockReset();
81+
mockGetSupportedWallets.mockResolvedValue([
82+
{
83+
id: 'freighter',
84+
name: 'Freighter',
85+
icon: undefined,
86+
isAvailable: true,
87+
},
88+
]);
89+
});
90+
91+
afterEach(() => {
92+
vi.useRealTimers();
93+
});
94+
95+
it('shows an inline error when wallet connection takes longer than 15 seconds', async () => {
96+
const hangingConnection = createDeferredPromise<{ address: string }>();
97+
mockGetAddress.mockReturnValue(hangingConnection.promise);
98+
99+
render(
100+
<WalletProvider connectionTimeoutMs={5}>
101+
<WalletHarness />
102+
</WalletProvider>
103+
);
104+
105+
fireEvent.click(screen.getByRole('button', { name: /open wallet modal/i }));
106+
107+
await act(async () => {
108+
await Promise.resolve();
109+
});
110+
111+
fireEvent.click(screen.getByRole('button', { name: /freighter/i }));
112+
113+
await waitFor(() => {
114+
expect(screen.getByRole('alert')).toHaveTextContent(
115+
'Wallet connection timed out after 15 seconds. Confirm the request in your wallet and try again.'
116+
);
117+
});
118+
119+
expect(screen.getByRole('dialog', { name: /connect to payd/i })).toBeInTheDocument();
120+
expect(mockNotifyWalletEvent).toHaveBeenCalledWith(
121+
'connection_failed',
122+
'Wallet connection timed out after 15 seconds. Confirm the request in your wallet and try again.'
123+
);
124+
});
125+
126+
it('closes the modal and updates wallet state after a successful connection', async () => {
127+
mockGetAddress.mockResolvedValue({ address: 'GABCD1234567890TESTWALLET' });
128+
129+
render(
130+
<WalletProvider connectionTimeoutMs={50}>
131+
<WalletHarness />
132+
</WalletProvider>
133+
);
134+
135+
fireEvent.click(screen.getByRole('button', { name: /open wallet modal/i }));
136+
137+
await act(async () => {
138+
await Promise.resolve();
139+
});
140+
141+
fireEvent.click(screen.getByRole('button', { name: /freighter/i }));
142+
143+
await waitFor(() => {
144+
expect(screen.getByText('GABCD1234567890TESTWALLET')).toBeInTheDocument();
145+
});
146+
147+
expect(screen.queryByRole('dialog', { name: /connect to payd/i })).not.toBeInTheDocument();
148+
expect(mockNotifyWalletEvent).toHaveBeenCalledWith(
149+
'connected',
150+
'GABCD1...LLET via freighter'
151+
);
152+
});
153+
154+
it('finishes initialization when silent reconnect hangs', async () => {
155+
localStorage.setItem('payd:last_wallet_name', 'freighter');
156+
const hangingReconnect = createDeferredPromise<{ address: string }>();
157+
mockGetAddress.mockReturnValue(hangingReconnect.promise);
158+
159+
render(
160+
<WalletProvider connectionTimeoutMs={5}>
161+
<div>Wallet-ready app</div>
162+
</WalletProvider>
163+
);
164+
165+
expect(screen.getByText(/restoring wallet session/i)).toBeInTheDocument();
166+
167+
await waitFor(() => {
168+
expect(screen.getByText('Wallet-ready app')).toBeInTheDocument();
169+
});
170+
expect(screen.queryByText(/restoring wallet session/i)).not.toBeInTheDocument();
171+
});
172+
});

0 commit comments

Comments
 (0)