forked from Creditra/Creditra-Frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathWalletContext.tsx
More file actions
542 lines (494 loc) · 21.5 KB
/
Copy pathWalletContext.tsx
File metadata and controls
542 lines (494 loc) · 21.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
/**
* WalletContext
*
* App-wide wallet state provider.
*
* Auto-reconnect on load
* ─────────────────────────────────────────────────────────────────────────────
* When a `wallet_info` entry is found in `localStorage`, the provider
* immediately kicks off a reconnect attempt instead of blindly restoring the
* stored object. This confirms the extension is still present and accessible
* before marking the wallet as "connected".
*
* Timeout banner
* ─────────────────────────────────────────────────────────────────────────────
* If the reconnect attempt has not resolved within `RECONNECT_TIMEOUT_MS`
* (default 8 000 ms), `reconnectTimedOut` is set to `true`. The
* `WalletReconnectBanner` component observes this flag and shows a
* non-blocking, dismissible banner with a manual retry action.
*
* The banner is intentionally non-blocking: the rest of the UI remains
* fully interactive while reconnect is in flight. Once the reconnect
* succeeds (or the user dismisses the banner), the flag resets.
*
* Lifecycle
* ─────────────────────────────────────────────────────────────────────────────
* disconnected — no stored wallet, no attempt in progress
* reconnecting — auto-reconnect attempt running on load
* connecting — user-initiated connection via WalletConnectionModal
* connected — wallet live and public key confirmed
* error — last connection attempt failed; `error` is populated
*
* Exported API changes (new surface area)
* ─────────────────────────────────────────────────────────────────────────────
* reconnectTimedOut: boolean
* True when the in-progress auto-reconnect has exceeded RECONNECT_TIMEOUT_MS.
* Resets to false when reconnect succeeds, fails, or is dismissed.
*
* dismissReconnectBanner(): void
* Clears the timeout banner without aborting the underlying reconnect.
* Useful when the user acknowledges the delay and wants to continue browsing.
*
* retryReconnect(): void
* Re-runs the auto-reconnect flow using the stored wallet preference.
* No-op if no preference is stored.
*
* Session-timeout banner (#227)
* ─────────────────────────────────────────────────────────────────────────────
* SESSION_TIMEOUT_MS
* Total wallet session lifetime (default 30 min). After this window the
* wallet extension silently drops the connection.
*
* sessionTimeoutWarning: boolean
* Becomes true SESSION_WARN_BEFORE_MS (60 s) before the session expires.
* Consumed by SessionTimeoutBanner to show the pre-disconnect warning.
* Resets to false when stayConnected() succeeds or when disconnect() is called.
*
* stayConnected(): Promise<void>
* Re-pings the wallet to verify liveness and resets the session clock.
* On success: sessionTimeoutWarning → false, full session timer restarts.
* On failure: transitions to 'error' state (same as a failed connect).
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import type { WalletInfo, ConnectionStatus, WalletError, WalletType } from '../types/wallet';
import {
connectWallet,
disconnectWallet,
saveWalletPreference,
getStoredWallet,
recordRecentWallet,
isWalletRemembered,
setWalletRemembered,
} from '../utils/wallet';
// ─── Constants ────────────────────────────────────────────────────────────────
/**
* How long (ms) to wait for a reconnect attempt before showing the
* "taking too long" banner. 8 s covers most slow-loading extensions
* without feeling laggy on fast connections (typical connect < 1 s).
*
* Exposed so tests can override it without patching timers globally.
*/
export const RECONNECT_TIMEOUT_MS = 8_000;
/**
* Total wallet session lifetime in ms (default 30 min).
* After this window elapses the wallet extension silently disconnects.
* Exposed so tests can pass a short override via `sessionTimeoutMs` prop.
*/
export const SESSION_TIMEOUT_MS = 30 * 60 * 1_000;
/**
* How far before session expiry (ms) to show the pre-disconnect warning banner.
* Fixed at 60 s per the issue spec.
*/
export const SESSION_WARN_BEFORE_MS = 60_000;
// ─── Types ────────────────────────────────────────────────────────────────────
interface BalanceInfo {
asset: string; // e.g., 'XLM' or asset_code
balance: string;
}
/** Optional second-arg bag for `connect()`. */
export interface ConnectOptions {
/**
* When `true`, mark this wallet as the user's "remembered" one. The
* provider will auto-reconnect to it on subsequent page loads and the
* wallet dropdown exposes a "Forget choice" affordance so the user
* can revoke that decision. Defaults to `false` to keep the behaviour
* opt-in: a passive click that does not pass this flag will connect
* just for the current session, with no persisted preference.
*/
remember?: boolean;
}
interface WalletContextType {
/** The currently connected wallet, or `null` when disconnected. */
wallet: WalletInfo | null;
/** Coarse connection-lifecycle state. UI consumers branch on this for spinners and badges. */
status: ConnectionStatus;
/**
* Discriminated wallet-error union, populated only when `status === 'error'`.
* Branch on `error.type` to render specific recovery copy.
*/
error: WalletError | null;
/**
* `true` when an auto-reconnect is in flight and has exceeded
* `RECONNECT_TIMEOUT_MS`. The `WalletReconnectBanner` subscribes to
* this to show the "taking longer than expected" affordance.
*/
reconnectTimedOut: boolean;
/**
* Mirror of the opt-in "remember my choice" flag in `localStorage`.
* `true` only when the user has explicitly opted in on their most
* recent connection. Drives the `WalletConnectionModal` and the
* `Forget` affordance in the wallet dropdown.
*/
isRemembered: boolean;
/**
* Open a connection to the given wallet. Updates `status` to
* `connecting`, then either `connected` (on success) or `error`.
*
* Successful connections are added to the MRU list so the modal can
* order wallets by recency. Passing `{ remember: true }` additionally
* persists an opt-in flag that drives next-visit auto-reconnect. The
* flag defaults to `false` because acceptance criteria require the
* preference to be opt-in, not pre-checked.
*/
connect: (type: WalletType, options?: ConnectOptions) => Promise<void>;
/** Forget the current wallet, clear preference, return to disconnected state. */
disconnect: () => void;
/**
* Clear only the "remember my choice" flag without disconnecting the
* wallet for the current session. Useful when the user wants to stay
* signed in *now* but stop the app from auto-connecting next time.
* After this call, `isRemembered` becomes `false` and `isWalletRemembered()`
* returns `false`.
*/
forgetRememberedChoice: () => void;
/** Clear an error without changing status — used by retry affordances. */
clearError: () => void;
/**
* Dismiss the reconnect-timeout banner without aborting the in-flight
* reconnect. The banner will not reappear until the next page load.
*/
dismissReconnectBanner: () => void;
/**
* Re-run the auto-reconnect using the stored wallet type. No-op when
* no stored preference exists. Resets `reconnectTimedOut` and starts
* a fresh timeout window.
*/
retryReconnect: () => void;
/**
* Re-ping the wallet to verify liveness and reset the session clock.
* On success: `sessionTimeoutWarning` clears and the full session timer restarts.
* On failure: status transitions to `'error'`.
*/
stayConnected: () => Promise<void>;
refreshBalance: () => Promise<void>;
setDropdownOpen: (open: boolean) => void;
balances: BalanceInfo[] | null;
lastUpdated: Date | null;
}
// ─── Context ──────────────────────────────────────────────────────────────────
const WalletContext = createContext<WalletContextType | undefined>(undefined);
// ─── Provider ─────────────────────────────────────────────────────────────────
/**
* App-wide wallet provider.
*
* Mount this once, near the top of the tree (see `src/App.tsx`), above any
* component that calls `useWallet()`.
*
* @param timeoutMs - Override the reconnect timeout (used in tests).
*/
export const WalletProvider = ({
children,
timeoutMs = RECONNECT_TIMEOUT_MS,
sessionTimeoutMs = SESSION_TIMEOUT_MS,
}: {
children: ReactNode;
/** Override the reconnect timeout duration (ms). Defaults to RECONNECT_TIMEOUT_MS. */
timeoutMs?: number;
/** Override the session lifetime (ms). Defaults to SESSION_TIMEOUT_MS. */
sessionTimeoutMs?: number;
}) => {
const [wallet, setWallet] = useState<WalletInfo | null>(null);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [error, setError] = useState<WalletError | null>(null);
const [reconnectTimedOut, setReconnectTimedOut] = useState(false);
// Initialised from `localStorage` via the safe wrapper so the very first
// render reflects whether the user opted in on a previous session.
const [isRemembered, setIsRemembered] = useState<boolean>(() => isWalletRemembered());
// Ref so the timeout cleanup in `runReconnect` closes over a stable reference.
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Session-timeout refs: warn timer fires 60 s before expiry; expire timer fires at expiry.
const sessionWarnTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sessionExpireTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/**
* Clear any pending reconnect timeout timer.
* Safe to call even when no timer is active.
*/
const clearReconnectTimeout = useCallback(() => {
if (reconnectTimeoutRef.current !== null) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
}, []);
/** Cancel both session-timeout timers. Safe to call when none are active. */
const clearSessionTimers = useCallback(() => {
if (sessionWarnTimerRef.current !== null) {
clearTimeout(sessionWarnTimerRef.current);
sessionWarnTimerRef.current = null;
}
if (sessionExpireTimerRef.current !== null) {
clearTimeout(sessionExpireTimerRef.current);
sessionExpireTimerRef.current = null;
}
}, []);
/**
* Start (or restart) the session-lifetime timers.
* - At (sessionTimeoutMs - SESSION_WARN_BEFORE_MS): set sessionTimeoutWarning = true.
* - At sessionTimeoutMs: auto-disconnect (wallet silently gone).
*/
const startSessionTimers = useCallback(() => {
clearSessionTimers();
setSessionTimeoutWarning(false);
const warnDelay = sessionTimeoutMs - SESSION_WARN_BEFORE_MS;
if (warnDelay > 0) {
sessionWarnTimerRef.current = setTimeout(() => {
setSessionTimeoutWarning(true);
}, warnDelay);
} else {
// Session shorter than warning window — warn immediately.
setSessionTimeoutWarning(true);
}
sessionExpireTimerRef.current = setTimeout(() => {
setSessionTimeoutWarning(false);
setWallet(null);
setStatus('disconnected');
}, sessionTimeoutMs);
}, [clearSessionTimers, sessionTimeoutMs]);
/**
* Core reconnect logic.
*
* 1. Sets `status` to `'reconnecting'`.
* 2. Starts a `timeoutMs` timer; if it fires before the connect resolves,
* `reconnectTimedOut` becomes `true` (banner appears).
* 3. Calls `connectWallet(type)` which talks to the browser extension.
* 4. On success → `connected`, clears timer.
* 5. On failure → `error`, clears timer.
*
* The function is intentionally not exported; callers use `retryReconnect`.
*/
const runReconnect = useCallback(
async (type: WalletType) => {
clearReconnectTimeout();
setStatus('reconnecting');
setError(null);
setReconnectTimedOut(false);
// Start the "taking too long" timer.
reconnectTimeoutRef.current = setTimeout(() => {
setReconnectTimedOut(true);
}, timeoutMs);
try {
const walletInfo = await connectWallet(type);
clearReconnectTimeout();
setWallet(walletInfo);
setStatus('connected');
setReconnectTimedOut(false);
saveWalletPreference(walletInfo);
// Auto-reconnect path is not gated by the user opt-in: the user
// already opted in on the previous session, which is precisely why
// we are running this reconnect.
recordRecentWallet(type);
setIsRemembered(true);
} catch (err) {
clearReconnectTimeout();
setReconnectTimedOut(false);
clearSessionTimers();
setError(err as WalletError);
setStatus('error');
setWallet(null);
}
},
[clearReconnectTimeout, timeoutMs, startSessionTimers, clearSessionTimers],
);
// ── Auto-reconnect on mount ─────────────────────────────────────────────────
useEffect(() => {
// Auto-reconnect is now an explicit opt-in. Without the
// `creditra-wallet-remember` flag we leave the user on the connect
// screen (matching the privacy-first design: the app must never
// silently re-establish a wallet session).
const stored = getStoredWallet();
const remembered = isWalletRemembered();
if (!stored || !remembered) return; // No prior agreed reconnect.
runReconnect(stored.type);
// Cleanup: if the component unmounts mid-reconnect, clear the timeout
// so we don't update state on an unmounted provider.
return () => clearReconnectTimeout();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run exactly once on mount.
// ── User-initiated connection ───────────────────────────────────────────────
const connect = async (type: WalletType, options?: ConnectOptions) => {
clearReconnectTimeout();
setStatus('connecting');
setError(null);
setReconnectTimedOut(false);
// Default to NOT remembering — the choice must be opt-in.
const shouldRemember = options?.remember === true;
try {
const walletInfo = await connectWallet(type);
setWallet(walletInfo);
setStatus('connected');
saveWalletPreference(walletInfo);
// Always promote the wallet to the front of MRU so the modal can
// surface it first, regardless of the remember-flag decision.
recordRecentWallet(type);
setWalletRemembered(shouldRemember);
setIsRemembered(shouldRemember);
} catch (err) {
setError(err as WalletError);
setStatus('error');
setWallet(null);
// Don't write any remembered state on failure: leaving stale
// persistence around has confused earlier releases.
}
};
// ── Disconnect ─────────────────────────────────────────────────────────────
const disconnect = () => {
clearReconnectTimeout();
// `disconnectWallet()` is intentionally aggressive: it clears the
// session AND the opt-in flag AND the MRU list. This means a
// deliberate disconnect is also a complete privacy reset, so the
// next visit will not auto-connect and will not pre-order the
// modal for the user.
disconnectWallet();
setWallet(null);
setStatus('disconnected');
setError(null);
setReconnectTimedOut(false);
setIsRemembered(false);
};
// ── "Forget remembered choice" (privacy control, not disconnect) ───────────
/**
* Revoke the opt-in to auto-reconnect next time without disconnecting the
* current session. After this call the wallet stays connected, the MRU
* list is left intact so the modal ordering still reflects past use, but
* `isWalletRemembered()` will return `false` until the user opts in again.
*/
const forgetRememberedChoice = useCallback(() => {
setWalletRemembered(false);
setIsRemembered(false);
}, []);
// ── Banner controls ────────────────────────────────────────────────────────
const dismissReconnectBanner = useCallback(() => {
setReconnectTimedOut(false);
}, []);
const retryReconnect = useCallback(() => {
const stored = getStoredWallet();
if (!stored) return;
runReconnect(stored.type);
}, [runReconnect]);
/**
* Re-ping the wallet to verify liveness and restart the session clock.
*
* Uses the current wallet type from the stored preference. On success
* `sessionTimeoutWarning` clears and the full session timer restarts.
* On failure the context transitions to `'error'`, matching the behaviour
* of a failed manual connect.
*
* No-op when no wallet is currently connected.
*/
const stayConnected = useCallback(async () => {
const stored = getStoredWallet();
if (!stored || !wallet) return;
try {
const walletInfo = await connectWallet(stored.type);
setWallet(walletInfo);
setStatus('connected');
saveWalletPreference(walletInfo);
startSessionTimers(); // full timer restart
} catch (err) {
clearSessionTimers();
setSessionTimeoutWarning(false);
setError(err as WalletError);
setStatus('error');
setWallet(null);
}
}, [wallet, startSessionTimers, clearSessionTimers]);
// ── Misc ───────────────────────────────────────────────────────────────────
const clearError = () => setError(null);
const [balances, setBalances] = useState<BalanceInfo[] | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const pollInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchBalances = async () => {
if (!wallet?.publicKey) return;
try {
const networkUrl =
wallet.network === 'public'
? 'https://horizon.stellar.org'
: 'https://horizon-testnet.stellar.org';
const response = await fetch(`${networkUrl}/accounts/${wallet.publicKey}`);
const data = await response.json();
const bal: BalanceInfo[] = data.balances.map((b: { asset_type: string; asset_code: string; balance: string }) => ({
asset: b.asset_type === 'native' ? 'XLM' : b.asset_code,
balance: b.balance,
}));
setBalances(bal);
setLastUpdated(new Date());
} catch (e) {
console.error('Failed to fetch balances', e);
setBalances(null);
}
};
const refreshBalance = async () => {
await fetchBalances();
};
const setDropdownOpen = (open: boolean) => {
if (open) {
fetchBalances();
pollInterval.current = setInterval(fetchBalances, 30_000);
} else if (pollInterval.current) {
clearInterval(pollInterval.current);
pollInterval.current = null;
}
};
// Clean up polling interval on unmount.
useEffect(() => {
return () => {
if (pollInterval.current) clearInterval(pollInterval.current);
clearSessionTimers();
};
}, [clearSessionTimers]);
return (
<WalletContext.Provider
value={{
wallet,
status,
error,
reconnectTimedOut,
isRemembered,
connect,
disconnect,
forgetRememberedChoice,
clearError,
dismissReconnectBanner,
retryReconnect,
stayConnected,
balances,
lastUpdated,
refreshBalance,
setDropdownOpen,
}}
>
{children}
</WalletContext.Provider>
);
};
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Read the wallet context inside a `WalletProvider` subtree.
*
* Throws if called outside the provider — this is intentional, since a
* silent `undefined` would cascade into confusing downstream errors
* (e.g. "cannot read property 'wallet' of undefined") far from the
* actual misuse.
*/
export const useWallet = () => {
const context = useContext(WalletContext);
if (!context) throw new Error('useWallet must be used within WalletProvider');
return context;
};