From 6e1d72259278785dbc4f4186baf9d6369e86bb3e Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Fri, 27 Mar 2026 18:51:04 +0100 Subject: [PATCH 1/3] refactor(frontend): decouple wallet logic into useWalletManager hook --- .../__tests__/hooks/useWalletManager.test.ts | 97 +++++++++ frontend/src/hooks/useWalletManager.ts | 198 ++++++++++++++++++ frontend/src/providers/WalletProvider.tsx | 194 ++--------------- 3 files changed, 316 insertions(+), 173 deletions(-) create mode 100644 frontend/src/__tests__/hooks/useWalletManager.test.ts create mode 100644 frontend/src/hooks/useWalletManager.ts diff --git a/frontend/src/__tests__/hooks/useWalletManager.test.ts b/frontend/src/__tests__/hooks/useWalletManager.test.ts new file mode 100644 index 00000000..62634fa0 --- /dev/null +++ b/frontend/src/__tests__/hooks/useWalletManager.test.ts @@ -0,0 +1,97 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach, MockedFunction } from 'vitest'; +import { useWalletManager } from '../../hooks/useWalletManager'; +import { StellarWalletsKit } from '@creit.tech/stellar-wallets-kit'; + +vi.mock('@creit.tech/stellar-wallets-kit', () => ({ + StellarWalletsKit: vi.fn(function() { return {}; }), + WalletNetwork: { TESTNET: 'TESTNET', PUBLIC: 'PUBLIC' }, + FreighterModule: vi.fn(function() { return {}; }), + xBullModule: vi.fn(function() { return {}; }), + LobstrModule: vi.fn(function() { return {}; }), + FREIGHTER_ID: 'freighter', + LOBSTR_ID: 'lobstr', +})); + +vi.mock('../../hooks/useNotification', () => ({ + useNotification: () => ({ + notifyWalletEvent: vi.fn(), + }), +})); + +describe('useWalletManager', () => { + let mockKitInstance: any; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockKitInstance = { + setWallet: vi.fn(), + getAddress: vi.fn(), + getSupportedWallets: vi.fn().mockResolvedValue([]), + disconnect: vi.fn(), + signTransaction: vi.fn(), + }; + (StellarWalletsKit as unknown as ReturnType).mockImplementation(function() { return mockKitInstance; }); + }); + + it('initializes and attempts silent reconnect if wallet in localStorage', async () => { + localStorage.setItem('payd:last_wallet_name', 'freighter'); + mockKitInstance.getAddress.mockResolvedValue({ address: 'G123' }); + + const { result } = renderHook(() => useWalletManager()); + + // Initially connecting + expect(result.current.isConnecting).toBe(true); + expect(result.current.isInitialized).toBe(false); + + await waitFor(() => { + expect(result.current.isInitialized).toBe(true); + }); + + expect(result.current.address).toBe('G123'); + expect(result.current.walletName).toBe('freighter'); + expect(result.current.isConnecting).toBe(false); + }); + + it('handles manual connect sequence appropriately', async () => { + mockKitInstance.getSupportedWallets.mockResolvedValue([ + { id: 'freighter', name: 'Freighter', isAvailable: true } + ]); + + const { result } = renderHook(() => useWalletManager()); + + act(() => { + result.current.connect(); + }); + + await waitFor(() => { + expect(result.current.walletModalOpen).toBe(true); + }); + expect(result.current.walletOptions.length).toBe(1); + + mockKitInstance.getAddress.mockResolvedValue({ address: 'G456' }); + + act(() => { + result.current.connectWithWallet('freighter'); + }); + + await waitFor(() => { + expect(result.current.walletModalOpen).toBe(false); + }); + expect(result.current.address).toBe('G456'); + expect(result.current.walletName).toBe('freighter'); + }); + + it('handles disconnect', async () => { + const { result } = renderHook(() => useWalletManager()); + + await act(async () => { + result.current.disconnect(); + }); + + expect(result.current.address).toBeNull(); + expect(result.current.walletName).toBeNull(); + expect(localStorage.getItem('payd:last_wallet_name')).toBeNull(); + }); +}); diff --git a/frontend/src/hooks/useWalletManager.ts b/frontend/src/hooks/useWalletManager.ts new file mode 100644 index 00000000..6e58eec3 --- /dev/null +++ b/frontend/src/hooks/useWalletManager.ts @@ -0,0 +1,198 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { + StellarWalletsKit, + WalletNetwork, + FreighterModule, + FREIGHTER_ID, + xBullModule, + LobstrModule, + LOBSTR_ID, +} from '@creit.tech/stellar-wallets-kit'; +import { useNotification } from './useNotification'; + +const LAST_WALLET_STORAGE_KEY = 'payd:last_wallet_name'; +const SUPPORTED_MODAL_WALLETS = [FREIGHTER_ID, LOBSTR_ID] as const; + +export type SelectableWallet = { + id: string; + name: string; + icon?: string; + isAvailable: boolean; +}; + +function hasAnyWalletExtension(): boolean { + if (typeof window === 'undefined') return true; + const extendedWindow = window as Window & + typeof globalThis & { + freighterApi?: unknown; + xBullSDK?: unknown; + lobstr?: unknown; + }; + + return Boolean(extendedWindow.freighterApi || extendedWindow.xBullSDK || extendedWindow.lobstr); +} + +export function useWalletManager() { + const [address, setAddress] = useState(null); + const [walletName, setWalletName] = useState(null); + const [isConnecting, setIsConnecting] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + const [walletExtensionAvailable, setWalletExtensionAvailable] = useState(true); + const [network, setNetwork] = useState<'TESTNET' | 'PUBLIC'>('TESTNET'); + const [walletModalOpen, setWalletModalOpen] = useState(false); + const [walletOptions, setWalletOptions] = useState([]); + const kitRef = useRef(null); + + const { notifyWalletEvent } = useNotification(); + + useEffect(() => { + setWalletExtensionAvailable(hasAnyWalletExtension()); + + const newKit = new StellarWalletsKit({ + network: network === 'TESTNET' ? WalletNetwork.TESTNET : WalletNetwork.PUBLIC, + modules: [new FreighterModule(), new xBullModule(), new LobstrModule()], + }); + kitRef.current = newKit; + + const attemptSilentReconnect = async () => { + const lastWalletName = localStorage.getItem(LAST_WALLET_STORAGE_KEY); + if (!lastWalletName) { + setIsInitialized(true); + return; + } + + setWalletName(lastWalletName); + setIsConnecting(true); + + try { + newKit.setWallet(lastWalletName); + const account = await newKit.getAddress(); + if (account?.address) { + setAddress(account.address); + notifyWalletEvent( + 'reconnected', + `${account.address.slice(0, 6)}...${account.address.slice(-4)} via ${lastWalletName}` + ); + } + } catch { + // Silent reconnection should not block app flow. + } finally { + setIsConnecting(false); + setIsInitialized(true); + } + }; + + void attemptSilentReconnect(); + }, [notifyWalletEvent, network]); + + const loadWalletOptions = useCallback(async (): Promise => { + const kit = kitRef.current; + if (!kit) return []; + const supported = await kit.getSupportedWallets(); + const options = supported + .filter((wallet) => + SUPPORTED_MODAL_WALLETS.includes(wallet.id as (typeof SUPPORTED_MODAL_WALLETS)[number]) + ) + .map((wallet) => ({ + id: wallet.id, + name: wallet.name, + icon: wallet.icon, + isAvailable: wallet.isAvailable, + })); + setWalletOptions(options); + setWalletExtensionAvailable(options.some((wallet) => wallet.isAvailable)); + return options; + }, []); + + const connectWithWallet = useCallback(async (selectedWalletId: string): Promise => { + const kit = kitRef.current; + if (!kit) return null; + + setIsConnecting(true); + try { + kit.setWallet(selectedWalletId); + + let timeoutId: ReturnType; + const timeoutPromise = new Promise<{ address: string }>((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('Connection timed out after 15 seconds.')), 15000); + }); + + const { address: newAddress } = await Promise.race([ + kit.getAddress(), + timeoutPromise, + ]); + clearTimeout(timeoutId!); + + setAddress(newAddress); + setWalletName(selectedWalletId); + localStorage.setItem(LAST_WALLET_STORAGE_KEY, selectedWalletId); + notifyWalletEvent( + 'connected', + `${newAddress.slice(0, 6)}...${newAddress.slice(-4)} via ${selectedWalletId}` + ); + return newAddress; + } catch (error) { + console.error('Failed to connect wallet:', error); + notifyWalletEvent( + 'connection_failed', + error instanceof Error ? error.message : 'Please try again.' + ); + return null; + } finally { + setIsConnecting(false); + setWalletModalOpen(false); + } + }, [notifyWalletEvent]); + + const connect = useCallback(async (): Promise => { + const options = await loadWalletOptions(); + if (options.length === 0) { + notifyWalletEvent('connection_failed', 'No supported wallet providers were found.'); + return null; + } + setWalletModalOpen(true); + return null; + }, [loadWalletOptions, notifyWalletEvent]); + + const requireWallet = useCallback(async (): Promise => { + if (address) return address; + notifyWalletEvent('required', 'Connect your wallet to continue with this contract action.'); + return connect(); + }, [address, connect, notifyWalletEvent]); + + const disconnect = useCallback(() => { + const kit = kitRef.current; + if (kit) { + void kit.disconnect(); + } + setAddress(null); + setWalletName(null); + localStorage.removeItem(LAST_WALLET_STORAGE_KEY); + notifyWalletEvent('disconnected'); + }, [notifyWalletEvent]); + + const signTransaction = useCallback(async (xdr: string) => { + const kit = kitRef.current; + if (!kit) throw new Error('Wallet kit not initialized'); + const result = await kit.signTransaction(xdr); + return result.signedTxXdr; + }, []); + + return { + address, + walletName, + network, + setNetwork, + isConnecting, + isInitialized, + walletExtensionAvailable, + connect, + requireWallet, + disconnect, + signTransaction, + walletModalOpen, + setWalletModalOpen, + walletOptions, + connectWithWallet, + }; +} diff --git a/frontend/src/providers/WalletProvider.tsx b/frontend/src/providers/WalletProvider.tsx index 82ca9063..b4a4dcfe 100644 --- a/frontend/src/providers/WalletProvider.tsx +++ b/frontend/src/providers/WalletProvider.tsx @@ -1,180 +1,28 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { - StellarWalletsKit, - WalletNetwork, - FreighterModule, - FREIGHTER_ID, - xBullModule, - LobstrModule, - LOBSTR_ID, -} from '@creit.tech/stellar-wallets-kit'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useNotification } from '../hooks/useNotification'; import { WalletContext } from '../hooks/useWallet'; - -const LAST_WALLET_STORAGE_KEY = 'payd:last_wallet_name'; -const SUPPORTED_MODAL_WALLETS = [FREIGHTER_ID, LOBSTR_ID] as const; - -type SelectableWallet = { - id: string; - name: string; - icon?: string; - isAvailable: boolean; -}; - -function hasAnyWalletExtension(): boolean { - if (typeof window === 'undefined') return true; - const extendedWindow = window as Window & - typeof globalThis & { - freighterApi?: unknown; - xBullSDK?: unknown; - lobstr?: unknown; - }; - - return Boolean(extendedWindow.freighterApi || extendedWindow.xBullSDK || extendedWindow.lobstr); -} +import { useWalletManager } from '../hooks/useWalletManager'; export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [address, setAddress] = useState(null); - const [walletName, setWalletName] = useState(null); - const [isConnecting, setIsConnecting] = useState(false); - const [isInitialized, setIsInitialized] = useState(false); - const [walletExtensionAvailable, setWalletExtensionAvailable] = useState(true); - const [network, setNetwork] = useState<'TESTNET' | 'PUBLIC'>('TESTNET'); - const [walletModalOpen, setWalletModalOpen] = useState(false); - const [walletOptions, setWalletOptions] = useState([]); - const kitRef = useRef(null); const { t } = useTranslation(); - const { notifyWalletEvent } = useNotification(); - - useEffect(() => { - setWalletExtensionAvailable(hasAnyWalletExtension()); - - const newKit = new StellarWalletsKit({ - network: network === 'TESTNET' ? WalletNetwork.TESTNET : WalletNetwork.PUBLIC, - modules: [new FreighterModule(), new xBullModule(), new LobstrModule()], - }); - kitRef.current = newKit; - - const attemptSilentReconnect = async () => { - const lastWalletName = localStorage.getItem(LAST_WALLET_STORAGE_KEY); - if (!lastWalletName) { - setIsInitialized(true); - return; - } - - setWalletName(lastWalletName); - setIsConnecting(true); - - try { - newKit.setWallet(lastWalletName); - const account = await newKit.getAddress(); - if (account?.address) { - setAddress(account.address); - notifyWalletEvent( - 'reconnected', - `${account.address.slice(0, 6)}...${account.address.slice(-4)} via ${lastWalletName}` - ); - } - } catch { - // Silent reconnection should not block app flow. - } finally { - setIsConnecting(false); - setIsInitialized(true); - } - }; - - void attemptSilentReconnect(); - }, [notifyWalletEvent, network]); - - const loadWalletOptions = async (): Promise => { - const kit = kitRef.current; - if (!kit) return []; - const supported = await kit.getSupportedWallets(); - const options = supported - .filter((wallet) => - SUPPORTED_MODAL_WALLETS.includes(wallet.id as (typeof SUPPORTED_MODAL_WALLETS)[number]) - ) - .map((wallet) => ({ - id: wallet.id, - name: wallet.name, - icon: wallet.icon, - isAvailable: wallet.isAvailable, - })); - setWalletOptions(options); - setWalletExtensionAvailable(options.some((wallet) => wallet.isAvailable)); - return options; - }; - - const connectWithWallet = async (selectedWalletId: string): Promise => { - const kit = kitRef.current; - if (!kit) return null; - - setIsConnecting(true); - try { - kit.setWallet(selectedWalletId); - - const { address } = await Promise.race([ - kit.getAddress(), - new Promise<{ address: string }>((_, reject) => - setTimeout(() => reject(new Error('Connection timed out after 15 seconds.')), 15000) - ), - ]); - - setAddress(address); - setWalletName(selectedWalletId); - localStorage.setItem(LAST_WALLET_STORAGE_KEY, selectedWalletId); - notifyWalletEvent( - 'connected', - `${address.slice(0, 6)}...${address.slice(-4)} via ${selectedWalletId}` - ); - return address; - } catch (error) { - console.error('Failed to connect wallet:', error); - notifyWalletEvent( - 'connection_failed', - error instanceof Error ? error.message : 'Please try again.' - ); - return null; - } finally { - setIsConnecting(false); - setWalletModalOpen(false); - } - }; - - const connect = async (): Promise => { - const options = await loadWalletOptions(); - if (options.length === 0) { - notifyWalletEvent('connection_failed', 'No supported wallet providers were found.'); - return null; - } - setWalletModalOpen(true); - return null; - }; - - const requireWallet = async (): Promise => { - if (address) return address; - notifyWalletEvent('required', 'Connect your wallet to continue with this contract action.'); - return connect(); - }; - - const disconnect = () => { - const kit = kitRef.current; - if (kit) { - void kit.disconnect(); - } - setAddress(null); - setWalletName(null); - localStorage.removeItem(LAST_WALLET_STORAGE_KEY); - notifyWalletEvent('disconnected'); - }; - - const signTransaction = async (xdr: string) => { - const kit = kitRef.current; - if (!kit) throw new Error('Wallet kit not initialized'); - const result = await kit.signTransaction(xdr); - return result.signedTxXdr; - }; + const walletManager = useWalletManager(); + const { + address, + walletName, + network, + setNetwork, + isConnecting, + isInitialized, + walletExtensionAvailable, + connect, + requireWallet, + disconnect, + signTransaction, + walletModalOpen, + setWalletModalOpen, + walletOptions, + connectWithWallet, + } = walletManager; return ( <> @@ -188,7 +36,7 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
-

{t('wallet.modalTitle') || 'Select a wallet'}

+

{t('wallet.modalTitle')}