diff --git a/dashboard/jest.config.cjs b/dashboard/jest.config.cjs index cb1294c..87d18b3 100644 --- a/dashboard/jest.config.cjs +++ b/dashboard/jest.config.cjs @@ -4,6 +4,11 @@ module.exports = { setupFilesAfterEnv: ['/jest.setup.cjs'], extensionsToTreatAsEsm: ['.ts', '.tsx'], moduleNameMapper: { + '^@creit\\.tech/stellar-wallets-kit/modules/utils$': + '/src/test/stellarWalletsKitModulesMock.ts', + '^@creit\\.tech/stellar-wallets-kit$': + '/src/test/stellarWalletsKitMock.ts', + '^.*/config/stellarNetwork$': '/src/test/stellarNetworkMock.ts', '^(\\.{1,2}/.*)\\.js$': '$1', }, transform: { diff --git a/dashboard/src/components/WalletConnectButton.test.tsx b/dashboard/src/components/WalletConnectButton.test.tsx new file mode 100644 index 0000000..c085dcf --- /dev/null +++ b/dashboard/src/components/WalletConnectButton.test.tsx @@ -0,0 +1,58 @@ +import '@testing-library/jest-dom'; +import { render, screen, act } from '@testing-library/react'; +import * as kitReal from '@creit.tech/stellar-wallets-kit'; +import { WalletConnectButton } from './WalletConnectButton'; +import { useWalletStore } from '../store/walletStore'; +import { restoreWalletSession } from '../services/wallet'; + +const kit = kitReal as unknown as typeof import('../test/stellarWalletsKitMock'); + +const WALLET_ID_KEY = 'notify-chain:wallet-id'; +const WALLET_ADDRESS_KEY = 'notify-chain:wallet-address'; + +beforeEach(() => { + localStorage.clear(); + useWalletStore.setState({ + address: null, + isConnecting: false, + isReconnecting: false, + error: null, + }); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe('WalletConnectButton reconnection behaviour', () => { + it('shows a connected address that was restored from a previous session', () => { + useWalletStore.getState().setAddress('GABCDEFGHIJKLMNOP'); + + render(); + + expect(screen.getByText('GABCDE...MNOP')).toBeInTheDocument(); + expect(screen.getByLabelText('Disconnect wallet')).toBeInTheDocument(); + }); + + it('keeps the connected UI through a transient reconnection blip', async () => { + localStorage.setItem(WALLET_ID_KEY, 'freighter'); + localStorage.setItem(WALLET_ADDRESS_KEY, 'GABCDEFGHIJKLMNOP'); + useWalletStore.getState().setAddress('GABCDEFGHIJKLMNOP'); + + render(); + expect(screen.getByLabelText('Disconnect wallet')).toBeInTheDocument(); + + // Registers the kit's event handlers via the service's ensureInitialized(). + await act(async () => { + await restoreWalletSession(); + }); + + // A transient null state-update mid-reconnect must NOT drop the session. + act(() => { + kit.__emit('STATE_UPDATED', { address: null }); + }); + + expect(screen.getByLabelText('Disconnect wallet')).toBeInTheDocument(); + expect(screen.queryByText('Connect Wallet')).not.toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/components/WalletConnectButton.tsx b/dashboard/src/components/WalletConnectButton.tsx index 0b49800..811a2b1 100644 --- a/dashboard/src/components/WalletConnectButton.tsx +++ b/dashboard/src/components/WalletConnectButton.tsx @@ -10,8 +10,19 @@ function shortenAddress(address: string): string { export const WalletConnectButton = memo(function WalletConnectButton() { const address = useWalletStore((state) => state.address); const isConnecting = useWalletStore((state) => state.isConnecting); + const isReconnecting = useWalletStore((state) => state.isReconnecting); const error = useWalletStore((state) => state.error); + if (!address && isReconnecting) { + return ( +
+ +
+ ); + } + if (address) { return (
diff --git a/dashboard/src/config/stellarNetwork.ts b/dashboard/src/config/stellarNetwork.ts new file mode 100644 index 0000000..5f7ecb3 --- /dev/null +++ b/dashboard/src/config/stellarNetwork.ts @@ -0,0 +1,10 @@ +/** + * Resolves the configured Stellar network name from the Vite environment. + * + * Isolated in its own module so the `import.meta.env` access — which only Vite + * can evaluate — stays out of unit tests, where it is replaced via Jest's + * `moduleNameMapper` (see jest.config.cjs). + */ +export function getStellarNetworkName(): string { + return import.meta.env?.VITE_STELLAR_NETWORK ?? 'TESTNET'; +} diff --git a/dashboard/src/services/wallet.test.tsx b/dashboard/src/services/wallet.test.tsx new file mode 100644 index 0000000..fe3280c --- /dev/null +++ b/dashboard/src/services/wallet.test.tsx @@ -0,0 +1,158 @@ +import { jest } from '@jest/globals'; +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; + +const WALLET_ID_KEY = 'notify-chain:wallet-id'; +const WALLET_ADDRESS_KEY = 'notify-chain:wallet-address'; + +type KitMock = typeof import('../test/stellarWalletsKitMock'); +type WalletService = typeof import('./wallet'); +type WalletStoreModule = typeof import('../store/walletStore'); + +/** + * Reset the module registry and re-import the kit mock, the wallet service and + * the store as a fresh, isolated graph. The store hydrates its initial address + * from localStorage at import time, so callers MUST seed localStorage before + * calling this. + */ +async function load(): Promise<{ + kit: KitMock; + wallet: WalletService; + store: WalletStoreModule; +}> { + jest.resetModules(); + const kit = (await import( + '@creit.tech/stellar-wallets-kit' + )) as unknown as KitMock; + const store = await import('../store/walletStore'); + const wallet = await import('./wallet'); + return { kit, wallet, store }; +} + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe('restoreWalletSession', () => { + it('does nothing when no wallet was previously connected', async () => { + const { wallet, store, kit } = await load(); + + await wallet.restoreWalletSession(); + + expect(store.useWalletStore.getState().address).toBeNull(); + expect(store.useWalletStore.getState().isReconnecting).toBe(false); + expect(kit.__control.setWalletCalls).toEqual([]); + }); + + it('restores the address on a successful reconnect', async () => { + localStorage.setItem(WALLET_ID_KEY, 'freighter'); + const { wallet, store, kit } = await load(); + + kit.__control.fetchAddressImpl = async () => { + kit.__emit('STATE_UPDATED', { address: 'GNEWADDRESS' }); + }; + + await wallet.restoreWalletSession(); + + expect(kit.__control.setWalletCalls).toEqual(['freighter']); + expect(store.useWalletStore.getState().address).toBe('GNEWADDRESS'); + expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBe('GNEWADDRESS'); + expect(store.useWalletStore.getState().isReconnecting).toBe(false); + }); + + // The core bug: a transient refresh failure used to wipe the persisted + // wallet, so users "lost" their session on reload. It must now survive. + it('keeps the persisted session when the reconnect fetch fails transiently', async () => { + localStorage.setItem(WALLET_ID_KEY, 'freighter'); + localStorage.setItem(WALLET_ADDRESS_KEY, 'GPERSISTED'); + const { wallet, store, kit } = await load(); + + kit.__control.fetchAddressImpl = async () => { + throw new Error('RPC timeout'); + }; + + await wallet.restoreWalletSession(); + + // The saved wallet must survive a transient failure so the user stays + // connected on the next load instead of losing their session. + expect(localStorage.getItem(WALLET_ID_KEY)).toBe('freighter'); + expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBe('GPERSISTED'); + expect(store.useWalletStore.getState().address).toBe('GPERSISTED'); + expect(store.useWalletStore.getState().error).toBe('RPC timeout'); + expect(store.useWalletStore.getState().isReconnecting).toBe(false); + }); + + // React StrictMode double-invokes effects; two concurrent restores must not + // race each other. + it('coalesces concurrent restore calls into a single reconnection', async () => { + localStorage.setItem(WALLET_ID_KEY, 'freighter'); + const { wallet, kit } = await load(); + + let resolveFetch: () => void = () => {}; + kit.__control.fetchAddressImpl = () => + new Promise((resolve) => { + resolveFetch = resolve; + }); + + const first = wallet.restoreWalletSession(); + const second = wallet.restoreWalletSession(); + + expect(first).toBe(second); + + resolveFetch(); + await Promise.all([first, second]); + + expect(kit.__control.setWalletCalls).toEqual(['freighter']); + }); +}); + +describe('kit reactive events', () => { + it('ignores a transient null address while a wallet stays selected', async () => { + localStorage.setItem(WALLET_ID_KEY, 'freighter'); + localStorage.setItem(WALLET_ADDRESS_KEY, 'GLIVE'); + const { wallet, store, kit } = await load(); + + // Registers the kit event handlers via ensureInitialized(). + await wallet.restoreWalletSession(); + + kit.__emit('STATE_UPDATED', { address: null }); + + expect(store.useWalletStore.getState().address).toBe('GLIVE'); + expect(localStorage.getItem(WALLET_ID_KEY)).toBe('freighter'); + }); + + it('clears everything on an explicit disconnect event', async () => { + localStorage.setItem(WALLET_ID_KEY, 'freighter'); + localStorage.setItem(WALLET_ADDRESS_KEY, 'GLIVE'); + const { wallet, store, kit } = await load(); + + await wallet.restoreWalletSession(); + kit.__emit('DISCONNECT', {}); + + expect(store.useWalletStore.getState().address).toBeNull(); + expect(localStorage.getItem(WALLET_ID_KEY)).toBeNull(); + expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBeNull(); + }); +}); + +describe('disconnectWallet', () => { + it('clears local state even when the kit disconnect call throws', async () => { + localStorage.setItem(WALLET_ID_KEY, 'freighter'); + localStorage.setItem(WALLET_ADDRESS_KEY, 'GLIVE'); + const { wallet, store, kit } = await load(); + + kit.__control.disconnectImpl = async () => { + throw new Error('module offline'); + }; + + await wallet.disconnectWallet(); + + expect(store.useWalletStore.getState().address).toBeNull(); + expect(localStorage.getItem(WALLET_ID_KEY)).toBeNull(); + expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBeNull(); + }); +}); + diff --git a/dashboard/src/services/wallet.ts b/dashboard/src/services/wallet.ts index 7b332c3..46ca1be 100644 --- a/dashboard/src/services/wallet.ts +++ b/dashboard/src/services/wallet.ts @@ -10,11 +10,10 @@ import { getPersistedWalletId, setPersistedWalletId, } from '../store/walletStore'; +import { getStellarNetworkName } from '../config/stellarNetwork'; const NETWORK = - (import.meta.env.VITE_STELLAR_NETWORK ?? 'TESTNET') === 'PUBLIC' - ? Networks.PUBLIC - : Networks.TESTNET; + getStellarNetworkName() === 'PUBLIC' ? Networks.PUBLIC : Networks.TESTNET; let initialized = false; @@ -33,10 +32,20 @@ function ensureInitialized(): void { }); // The kit's own reactive state — fires immediately with current state on - // subscribe, then again on every change. This is the single source of - // truth for "is a wallet connected right now." + // subscribe, then again on every change. StellarWalletsKit.on(KitEventType.STATE_UPDATED, (event) => { - useWalletStore.getState().setAddress(event.payload.address ?? null); + const nextAddress = event.payload.address ?? null; + + // Mid-reconnection the kit can emit transient state updates that carry no + // address (e.g. while it re-derives the account). Honouring those would + // flip the UI back to "disconnected" and wipe the session even though a + // wallet is still selected. Only the dedicated DISCONNECT event below is + // allowed to tear a live session down. + if (!nextAddress && getPersistedWalletId()) { + return; + } + + useWalletStore.getState().setAddress(nextAddress); }); StellarWalletsKit.on(KitEventType.WALLET_SELECTED, (event) => { @@ -45,7 +54,7 @@ function ensureInitialized(): void { StellarWalletsKit.on(KitEventType.DISCONNECT, () => { setPersistedWalletId(null); - useWalletStore.getState().setAddress(null); + useWalletStore.getState().clearSession(); }); } @@ -57,6 +66,7 @@ function ensureInitialized(): void { export async function connectWallet(): Promise { ensureInitialized(); const store = useWalletStore.getState(); + store.setError(null); store.setConnecting(true); try { @@ -75,30 +85,56 @@ export async function disconnectWallet(): Promise { try { await StellarWalletsKit.disconnect(); } catch { - // Even if the module's own disconnect call fails, clear local state - // so the UI doesn't get stuck showing a stale "connected" status. + // A failed module-level disconnect shouldn't surface as an error or leave + // the UI stuck — we clear local state below regardless. + } finally { + // A disconnect is an explicit, intentional teardown — clear local state + // whether or not the module's own call succeeded. setPersistedWalletId(null); - useWalletStore.getState().setAddress(null); + useWalletStore.getState().clearSession(); } } +let restoreInFlight: Promise | null = null; + /** * Call once on app load. If a wallet was connected in a previous session, - * re-initializes the kit with that wallet selected and refreshes the - * address. If the wallet is no longer reachable, clears the stale session. + * re-selects it and refreshes the address. + * + * The persisted session is treated as the source of truth: a failed refresh + * (RPC blip, wallet extension still loading, account locked) is reported as a + * recoverable error but does NOT erase the saved wallet — the optimistically + * restored address keeps the user "connected" and a later retry can reconcile. + * Only an explicit disconnect clears persistence. + * + * Safe to call multiple times: concurrent calls share a single in-flight + * promise, so React StrictMode's double-invoked effects can't race each other + * into clearing a just-restored session. */ -export async function restoreWalletSession(): Promise { +export function restoreWalletSession(): Promise { ensureInitialized(); + + if (restoreInFlight) return restoreInFlight; + const walletId = getPersistedWalletId(); - if (!walletId) return; + if (!walletId) return Promise.resolve(); - try { - StellarWalletsKit.setWallet(walletId); - await StellarWalletsKit.fetchAddress(); - } catch { - setPersistedWalletId(null); - useWalletStore.getState().setAddress(null); - } + const store = useWalletStore.getState(); + store.setReconnecting(true); + + restoreInFlight = (async () => { + try { + StellarWalletsKit.setWallet(walletId); + await StellarWalletsKit.fetchAddress(); + } catch (err) { + store.setError(describeError(err)); + } finally { + store.setReconnecting(false); + restoreInFlight = null; + } + })(); + + return restoreInFlight; } function describeError(err: unknown): string { diff --git a/dashboard/src/store/walletStore.ts b/dashboard/src/store/walletStore.ts index efcbacc..df63396 100644 --- a/dashboard/src/store/walletStore.ts +++ b/dashboard/src/store/walletStore.ts @@ -3,28 +3,32 @@ import { create } from 'zustand'; export interface WalletState { address: string | null; isConnecting: boolean; + isReconnecting: boolean; error: string | null; setAddress: (address: string | null) => void; setConnecting: (isConnecting: boolean) => void; + setReconnecting: (isReconnecting: boolean) => void; setError: (error: string | null) => void; + clearSession: () => void; } -const STORAGE_KEY = 'notify-chain:wallet-id'; +const WALLET_ID_KEY = 'notify-chain:wallet-id'; +const WALLET_ADDRESS_KEY = 'notify-chain:wallet-address'; -export function getPersistedWalletId(): string | null { +function readStorage(key: string): string | null { try { - return localStorage.getItem(STORAGE_KEY); + return localStorage.getItem(key); } catch { return null; } } -export function setPersistedWalletId(walletId: string | null): void { +function writeStorage(key: string, value: string | null): void { try { - if (walletId) { - localStorage.setItem(STORAGE_KEY, walletId); + if (value) { + localStorage.setItem(key, value); } else { - localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(key); } } catch { // localStorage may be unavailable (private browsing, etc.) — connection @@ -32,12 +36,45 @@ export function setPersistedWalletId(walletId: string | null): void { } } +export function getPersistedWalletId(): string | null { + return readStorage(WALLET_ID_KEY); +} + +export function setPersistedWalletId(walletId: string | null): void { + writeStorage(WALLET_ID_KEY, walletId); +} + +/** + * The last resolved address is persisted alongside the wallet id so the UI can + * render the connected state immediately on reload — and so a transient + * reconnection failure doesn't drop the user back to "disconnected". + */ +export function getPersistedAddress(): string | null { + return readStorage(WALLET_ADDRESS_KEY); +} + +export function setPersistedAddress(address: string | null): void { + writeStorage(WALLET_ADDRESS_KEY, address); +} + export const useWalletStore = create((set) => ({ - address: null, + address: getPersistedAddress(), isConnecting: false, + isReconnecting: false, error: null, - setAddress: (address) => set({ address, error: null }), + setAddress: (address) => { + setPersistedAddress(address); + set({ address, error: null }); + }, setConnecting: (isConnecting) => set({ isConnecting }), - setError: (error) => set({ error, isConnecting: false }), -})); \ No newline at end of file + setReconnecting: (isReconnecting) => set({ isReconnecting }), + setError: (error) => set({ error, isConnecting: false, isReconnecting: false }), + + // Tear down a session completely: in-memory address, persisted address, and + // any transient flags. Used on an explicit disconnect only. + clearSession: () => { + setPersistedAddress(null); + set({ address: null, error: null, isConnecting: false, isReconnecting: false }); + }, +})); diff --git a/dashboard/src/test/stellarNetworkMock.ts b/dashboard/src/test/stellarNetworkMock.ts new file mode 100644 index 0000000..2c3ef82 --- /dev/null +++ b/dashboard/src/test/stellarNetworkMock.ts @@ -0,0 +1,8 @@ +/** + * Test replacement for ../config/stellarNetwork, wired in via Jest's + * `moduleNameMapper`. Avoids the `import.meta.env` access that the Jest runner + * (running modules as CommonJS) cannot evaluate. + */ +export function getStellarNetworkName(): string { + return process.env.VITE_STELLAR_NETWORK ?? 'TESTNET'; +} diff --git a/dashboard/src/test/stellarWalletsKitMock.ts b/dashboard/src/test/stellarWalletsKitMock.ts new file mode 100644 index 0000000..a97fa42 --- /dev/null +++ b/dashboard/src/test/stellarWalletsKitMock.ts @@ -0,0 +1,51 @@ +/** + * Test double for `@creit.tech/stellar-wallets-kit`, wired in via Jest's + * `moduleNameMapper`. It records the calls the wallet service makes and lets a + * test drive the kit's reactive events (STATE_UPDATED / WALLET_SELECTED / + * DISCONNECT) and the success/failure of its async methods. + */ + +type Handler = (event: { payload: Record }) => void; + +const listeners: Record = {}; + +export const Networks = { PUBLIC: 'PUBLIC', TESTNET: 'TESTNET' } as const; + +export const KitEventType = { + STATE_UPDATED: 'STATE_UPDATED', + WALLET_SELECTED: 'WALLET_SELECTED', + DISCONNECT: 'DISCONNECT', +} as const; + +export const ModuleType = { + HW_WALLET: 'HW_WALLET', + BRIDGE_WALLET: 'BRIDGE_WALLET', +} as const; + +export const __control = { + authModalImpl: async (): Promise => {}, + fetchAddressImpl: async (): Promise => {}, + disconnectImpl: async (): Promise => {}, + setWalletCalls: [] as string[], + initCalls: 0, +}; + +export const StellarWalletsKit = { + init: (): void => { + __control.initCalls += 1; + }, + on: (eventType: string, handler: Handler): void => { + (listeners[eventType] ||= []).push(handler); + }, + authModal: (): Promise => __control.authModalImpl(), + setWallet: (id: string): void => { + __control.setWalletCalls.push(id); + }, + fetchAddress: (): Promise => __control.fetchAddressImpl(), + disconnect: (): Promise => __control.disconnectImpl(), +}; + +/** Fire a kit event to every subscribed handler, mirroring the real kit. */ +export function __emit(eventType: string, payload: Record): void { + (listeners[eventType] || []).forEach((handler) => handler({ payload })); +} diff --git a/dashboard/src/test/stellarWalletsKitModulesMock.ts b/dashboard/src/test/stellarWalletsKitModulesMock.ts new file mode 100644 index 0000000..b709fc4 --- /dev/null +++ b/dashboard/src/test/stellarWalletsKitModulesMock.ts @@ -0,0 +1,8 @@ +/** + * Test double for `@creit.tech/stellar-wallets-kit/modules/utils`, wired in via + * Jest's `moduleNameMapper`. The wallet service only needs `defaultModules` to + * exist and accept a filter. + */ +export function defaultModules(): unknown[] { + return []; +}