From 3ae5d6c26f760ad5662314043059431d763153ed Mon Sep 17 00:00:00 2001 From: Deemexlyon <113914039+Deemexlyon@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:23:50 +0000 Subject: [PATCH] enhance(settings): integrate wallet management into profile account page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Wallet Management tab to the profile settings page so users can review the Web3 wallets linked to their account. The new tab renders the ConnectedWallets panel, fed by the useWalletManagement hook and walletService (Component -> Hook -> Service), and keeps the shared wallet context (useWalletStore) in sync when a wallet is selected or unlinked. Also restore the wallet session, transaction-status and multi-sig methods on walletService that a previous balance-only refactor had dropped, which left useWallet, useTxTracker and useMultiSigApprovals calling missing methods and broke the wallet service transaction tests. Closes #874 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- app/(dashboard)/settings/profile/page.tsx | 37 ++- components/wallet/ConnectedWallets.tsx | 241 ++++++++++++++++++ .../__tests__/ConnectedWallets.test.tsx | 118 +++++++++ hooks/__tests__/useWalletManagement.test.ts | 186 ++++++++++++++ hooks/useWalletManagement.ts | 144 +++++++++++ .../walletService.connectedWallets.test.ts | 104 ++++++++ services/walletService.ts | 188 +++++++++++++- types/wallet.types.ts | 23 ++ 8 files changed, 1035 insertions(+), 6 deletions(-) create mode 100644 components/wallet/ConnectedWallets.tsx create mode 100644 components/wallet/__tests__/ConnectedWallets.test.tsx create mode 100644 hooks/__tests__/useWalletManagement.test.ts create mode 100644 hooks/useWalletManagement.ts create mode 100644 services/__tests__/walletService.connectedWallets.test.ts diff --git a/app/(dashboard)/settings/profile/page.tsx b/app/(dashboard)/settings/profile/page.tsx index a50f985..627841a 100644 --- a/app/(dashboard)/settings/profile/page.tsx +++ b/app/(dashboard)/settings/profile/page.tsx @@ -10,9 +10,12 @@ import { Camera, Loader2, CheckCircle2, - AlertCircle + AlertCircle, + Wallet } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useWalletManagement } from '@/hooks/useWalletManagement'; +import { ConnectedWallets } from '@/components/wallet/ConnectedWallets'; interface ProfileFormValues { name: string; @@ -27,7 +30,8 @@ interface PasswordFormValues { export default function ProfileSettingsPage() { const { profile, isLoading, updateProfile, changePassword } = useUser(); - const [activeTab, setActiveTab] = useState<'profile' | 'security'>('profile'); + const walletManagement = useWalletManagement(); + const [activeTab, setActiveTab] = useState<'profile' | 'security' | 'wallet'>('profile'); const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); @@ -118,11 +122,23 @@ export default function ProfileSettingsPage() { Security + {/* Main Content */}
-
+
{activeTab === 'profile' ? (
@@ -235,7 +251,7 @@ export default function ProfileSettingsPage() {
- ) : ( + ) : activeTab === 'security' ? (

Change Password

@@ -316,6 +332,19 @@ export default function ProfileSettingsPage() {
+ ) : ( +
+ +
)}
diff --git a/components/wallet/ConnectedWallets.tsx b/components/wallet/ConnectedWallets.tsx new file mode 100644 index 0000000..8e49488 --- /dev/null +++ b/components/wallet/ConnectedWallets.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { + AlertCircle, + CheckCircle2, + Link2Off, + Loader2, + RefreshCw, + ShieldCheck, + Star, + Trash2, + Wallet, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { ConnectedWallet } from '@/types/wallet.types'; + +export interface ConnectedWalletsProps { + wallets: ConnectedWallet[]; + /** Address currently active in the shared wallet context. */ + activeAddress: string | null; + isLoading: boolean; + isDisconnecting: boolean; + error: string | null; + onRefresh: () => void; + onSetActive: (_wallet: ConnectedWallet) => void; + onDisconnect: (_wallet: ConnectedWallet) => void; +} + +const PROVIDER_LABELS: Record = { + freighter: 'Freighter', + walletconnect: 'WalletConnect', + ledger: 'Ledger', + albedo: 'Albedo', + xbull: 'xBull', + rabet: 'Rabet', +}; + +function formatProvider(provider: string): string { + return ( + PROVIDER_LABELS[provider.toLowerCase()] ?? + provider.charAt(0).toUpperCase() + provider.slice(1) + ); +} + +function truncateAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}…${address.slice(-6)}`; +} + +function formatConnectedAt(value: string): string | null { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +/** + * ConnectedWallets — presentational panel listing the Web3 wallets linked to + * the account. It renders data supplied by useWalletManagement and never talks + * to a service or the network directly. + * + * Layered Architecture: + * ConnectedWallets (Component) → useWalletManagement (Hook) → walletService (Service) + */ +export function ConnectedWallets({ + wallets, + activeAddress, + isLoading, + isDisconnecting, + error, + onRefresh, + onSetActive, + onDisconnect, +}: ConnectedWalletsProps) { + return ( +
+
+
+

Connected Wallets

+

+ Manage the Web3 wallets linked to your SwiftChain account. +

+
+ +
+ + {error && !isLoading && ( +
+ +
+

{error}

+ +
+
+ )} + + {isLoading && ( +
    + {[0, 1].map((index) => ( +
  • + ))} +
+ )} + + {!isLoading && !error && wallets.length === 0 && ( +
+ +

+ No wallets connected +

+

+ Connect a Stellar wallet from the dashboard to link it to your account. +

+
+ )} + + {!isLoading && wallets.length > 0 && ( +
    + {wallets.map((wallet) => { + const isActive = wallet.address === activeAddress; + const connectedAt = formatConnectedAt(wallet.connectedAt); + + return ( +
  • +
    + + +
    +
    + + {wallet.label || formatProvider(wallet.provider)} + + + {wallet.network} + + {wallet.isPrimary && ( + + + Primary + + )} + {isActive && ( + + + Active + + )} +
    +

    + {truncateAddress(wallet.address)} +

    + {connectedAt && ( +

    + Connected {connectedAt} +

    + )} +
    +
    + +
    + {!isActive && ( + + )} + +
    +
  • + ); + })} +
+ )} + + {!isLoading && wallets.length > 0 && ( +
+ +

+ Removing a wallet revokes its access to your escrow and delivery + funds. You can reconnect it at any time. +

+
+ )} +
+ ); +} diff --git a/components/wallet/__tests__/ConnectedWallets.test.tsx b/components/wallet/__tests__/ConnectedWallets.test.tsx new file mode 100644 index 0000000..c81ce66 --- /dev/null +++ b/components/wallet/__tests__/ConnectedWallets.test.tsx @@ -0,0 +1,118 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ConnectedWallets, type ConnectedWalletsProps } from '@/components/wallet/ConnectedWallets'; +import type { ConnectedWallet } from '@/types/wallet.types'; + +const WALLETS: ConnectedWallet[] = [ + { + id: 'w1', + address: 'GABC123DEF456STELLAR', + provider: 'freighter', + network: 'testnet', + isPrimary: true, + connectedAt: '2026-01-15T10:30:00Z', + }, + { + id: 'w2', + address: 'GDEF789GHI012STELLAR', + provider: 'ledger', + network: 'public', + isPrimary: false, + connectedAt: '2026-02-20T08:00:00Z', + }, +]; + +function makeProps(overrides: Partial = {}): ConnectedWalletsProps { + return { + wallets: WALLETS, + activeAddress: WALLETS[0].address, + isLoading: false, + isDisconnecting: false, + error: null, + onRefresh: jest.fn(), + onSetActive: jest.fn(), + onDisconnect: jest.fn(), + ...overrides, + }; +} + +describe('ConnectedWallets', () => { + it('renders every connected wallet with its provider and truncated address', () => { + render(); + + expect(screen.getByText('Freighter')).toBeInTheDocument(); + expect(screen.getByText('Ledger')).toBeInTheDocument(); + expect(screen.getByText('GABC12…TELLAR')).toBeInTheDocument(); + expect(screen.getByText('GDEF78…TELLAR')).toBeInTheDocument(); + }); + + it('marks the active wallet and hides its Set active action', () => { + render(); + + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /set active/i })).toHaveLength(1); + }); + + it('marks the primary wallet returned by the backend', () => { + render(); + + expect(screen.getByText('Primary')).toBeInTheDocument(); + }); + + it('calls onDisconnect with the wallet that was clicked', () => { + const props = makeProps(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /disconnect freighter wallet/i })); + + expect(props.onDisconnect).toHaveBeenCalledWith(WALLETS[0]); + }); + + it('calls onSetActive for a non-active wallet', () => { + const props = makeProps(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /set active/i })); + + expect(props.onSetActive).toHaveBeenCalledWith(WALLETS[1]); + }); + + it('calls onRefresh when the refresh button is clicked', () => { + const props = makeProps(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /refresh/i })); + + expect(props.onRefresh).toHaveBeenCalledTimes(1); + }); + + it('shows a loading skeleton while wallets are being fetched', () => { + render(); + + expect(screen.getByLabelText(/loading connected wallets/i)).toBeInTheDocument(); + expect(screen.queryByText(/no wallets connected/i)).not.toBeInTheDocument(); + }); + + it('shows the error state with a retry action', () => { + const props = makeProps({ wallets: [], error: 'Failed to load connected wallets' }); + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('Failed to load connected wallets'); + + fireEvent.click(screen.getByRole('button', { name: /try again/i })); + expect(props.onRefresh).toHaveBeenCalledTimes(1); + }); + + it('shows an empty state when the account has no wallets', () => { + render(); + + expect(screen.getByText(/no wallets connected/i)).toBeInTheDocument(); + }); + + it('disables the disconnect action while a disconnect is in flight', () => { + render(); + + expect( + screen.getByRole('button', { name: /disconnect freighter wallet/i }) + ).toBeDisabled(); + }); +}); diff --git a/hooks/__tests__/useWalletManagement.test.ts b/hooks/__tests__/useWalletManagement.test.ts new file mode 100644 index 0000000..42d9b7d --- /dev/null +++ b/hooks/__tests__/useWalletManagement.test.ts @@ -0,0 +1,186 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useWalletManagement } from '@/hooks/useWalletManagement'; +import { walletService } from '@/services/walletService'; +import { useWalletStore } from '@/store/walletStore'; +import type { ConnectedWallet } from '@/types/wallet.types'; + +jest.mock('@/services/walletService', () => ({ + walletService: { + getConnectedWallets: jest.fn(), + disconnectWallet: jest.fn(), + }, +})); + +const WALLETS: ConnectedWallet[] = [ + { + id: 'w1', + address: 'GABC123DEF456STELLAR', + provider: 'freighter', + network: 'testnet', + isPrimary: true, + connectedAt: '2026-01-15T10:30:00Z', + }, + { + id: 'w2', + address: 'GDEF789GHI012STELLAR', + provider: 'ledger', + network: 'public', + isPrimary: false, + connectedAt: '2026-02-20T08:00:00Z', + }, +]; + +describe('useWalletManagement', () => { + beforeEach(() => { + jest.clearAllMocks(); + act(() => { + useWalletStore.getState().clearWalletState(); + }); + localStorage.clear(); + }); + + it('loads connected wallets from the service', async () => { + (walletService.getConnectedWallets as jest.Mock).mockResolvedValueOnce(WALLETS); + + const { result } = renderHook(() => useWalletManagement()); + + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(walletService.getConnectedWallets).toHaveBeenCalledTimes(1); + expect(result.current.wallets).toEqual(WALLETS); + expect(result.current.error).toBeNull(); + }); + + it('surfaces the service error message when loading fails', async () => { + (walletService.getConnectedWallets as jest.Mock).mockRejectedValueOnce( + new Error('Backend unavailable') + ); + + const { result } = renderHook(() => useWalletManagement()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.error).toBe('Backend unavailable'); + expect(result.current.wallets).toEqual([]); + }); + + it('exposes the active wallet matching the wallet context address', async () => { + act(() => { + useWalletStore.getState().setWallet('GABC123DEF456STELLAR', 0); + }); + (walletService.getConnectedWallets as jest.Mock).mockResolvedValueOnce(WALLETS); + + const { result } = renderHook(() => useWalletManagement()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.activeWallet?.id).toBe('w1'); + expect(result.current.activeAddress).toBe('GABC123DEF456STELLAR'); + expect(result.current.isConnected).toBe(true); + }); + + it('clears a stale wallet session that is no longer linked on the backend', async () => { + act(() => { + useWalletStore.getState().setWallet('GSTALESTALE0000000000', 0); + }); + (walletService.getConnectedWallets as jest.Mock).mockResolvedValueOnce(WALLETS); + + const { result } = renderHook(() => useWalletManagement()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(result.current.activeAddress).toBeNull()); + expect(result.current.isConnected).toBe(false); + }); + + it('setActiveWallet syncs the selected wallet into the wallet context', async () => { + (walletService.getConnectedWallets as jest.Mock).mockResolvedValueOnce(WALLETS); + + const { result } = renderHook(() => useWalletManagement()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.setActiveWallet(WALLETS[1]); + }); + + expect(result.current.activeAddress).toBe('GDEF789GHI012STELLAR'); + expect(result.current.activeWallet?.id).toBe('w2'); + }); + + it('disconnectWallet removes the wallet and clears the context when it was active', async () => { + act(() => { + useWalletStore.getState().setWallet('GABC123DEF456STELLAR', 0); + }); + (walletService.getConnectedWallets as jest.Mock).mockResolvedValueOnce(WALLETS); + (walletService.disconnectWallet as jest.Mock).mockResolvedValueOnce({ + success: true, + message: 'Disconnected', + }); + + const { result } = renderHook(() => useWalletManagement()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let ok = false; + await act(async () => { + ok = await result.current.disconnectWallet(WALLETS[0]); + }); + + expect(ok).toBe(true); + expect(walletService.disconnectWallet).toHaveBeenCalledWith('w1'); + expect(result.current.wallets.map((wallet) => wallet.id)).toEqual(['w2']); + expect(result.current.activeAddress).toBeNull(); + }); + + it('keeps other wallets and the active context when disconnecting a non-active wallet', async () => { + act(() => { + useWalletStore.getState().setWallet('GABC123DEF456STELLAR', 0); + }); + (walletService.getConnectedWallets as jest.Mock).mockResolvedValueOnce(WALLETS); + (walletService.disconnectWallet as jest.Mock).mockResolvedValueOnce({ + success: true, + message: 'Disconnected', + }); + + const { result } = renderHook(() => useWalletManagement()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.disconnectWallet(WALLETS[1]); + }); + + expect(result.current.activeAddress).toBe('GABC123DEF456STELLAR'); + expect(result.current.wallets.map((wallet) => wallet.id)).toEqual(['w1']); + }); + + it('reports an error and keeps the wallet when disconnect fails', async () => { + (walletService.getConnectedWallets as jest.Mock).mockResolvedValueOnce(WALLETS); + (walletService.disconnectWallet as jest.Mock).mockRejectedValueOnce( + new Error('Could not unlink wallet') + ); + + const { result } = renderHook(() => useWalletManagement()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + let ok = true; + await act(async () => { + ok = await result.current.disconnectWallet(WALLETS[0]); + }); + + expect(ok).toBe(false); + expect(result.current.error).toBe('Could not unlink wallet'); + expect(result.current.wallets).toHaveLength(2); + }); + + it('refetch requests a fresh list from the service', async () => { + (walletService.getConnectedWallets as jest.Mock).mockResolvedValue(WALLETS); + + const { result } = renderHook(() => useWalletManagement()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.refetch(); + }); + + await waitFor(() => expect(walletService.getConnectedWallets).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/hooks/useWalletManagement.ts b/hooks/useWalletManagement.ts new file mode 100644 index 0000000..310bdbf --- /dev/null +++ b/hooks/useWalletManagement.ts @@ -0,0 +1,144 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import axios from 'axios'; +import { walletService } from '@/services/walletService'; +import { useWalletStore } from '@/store/walletStore'; +import type { ConnectedWallet } from '@/types/wallet.types'; + +export interface UseWalletManagementResult { + /** Every wallet linked to the account, as reported by the backend. */ + wallets: ConnectedWallet[]; + /** The connected wallet matching the wallet context (Zustand) address, if any. */ + activeWallet: ConnectedWallet | null; + activeAddress: string | null; + isConnected: boolean; + isLoading: boolean; + isDisconnecting: boolean; + error: string | null; + refetch: () => Promise; + /** Unlink a wallet. Returns true on success. */ + disconnectWallet: (_wallet: ConnectedWallet) => Promise; + /** Promote a wallet to the active session in the shared wallet context. */ + setActiveWallet: (_wallet: ConnectedWallet) => void; +} + +/** + * useWalletManagement — data layer for the Wallet Management settings tab. + * + * Layered Architecture: + * ConnectedWallets (Component) → useWalletManagement (Hook) → walletService (Service) + * + * The hook also keeps the shared wallet context (`useWalletStore`) in sync with + * the backend list: when the persisted address no longer exists on the server the + * stale session is cleared, and selecting a wallet writes it back to the store. + */ +export function useWalletManagement(): UseWalletManagementResult { + const address = useWalletStore((state) => state.address); + const isConnected = useWalletStore((state) => state.isConnected); + const setWallet = useWalletStore((state) => state.setWallet); + const clearWalletState = useWalletStore((state) => state.clearWalletState); + + const [wallets, setWallets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [error, setError] = useState(null); + const [reloadTick, setReloadTick] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + let cancelled = false; + + // eslint-disable-next-line react-hooks/set-state-in-effect + setIsLoading(true); + + walletService + .getConnectedWallets(controller.signal) + .then((data) => { + if (cancelled) return; + setWallets(data); + setError(null); + }) + .catch((err: unknown) => { + if (cancelled || axios.isCancel(err)) return; + const message = + err instanceof Error && err.message + ? err.message + : 'Failed to load connected wallets'; + setError(message); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [reloadTick]); + + const refetch = useCallback(async (): Promise => { + setError(null); + setReloadTick((tick) => tick + 1); + }, []); + + // Keep the wallet context in sync: drop a persisted session whose address is + // no longer linked to the account on the backend. + useEffect(() => { + if (isLoading || address === null) return; + const stillLinked = wallets.some((wallet) => wallet.address === address); + if (!stillLinked) { + clearWalletState(); + } + }, [wallets, address, isLoading, clearWalletState]); + + const activeWallet = useMemo( + () => wallets.find((wallet) => wallet.address === address) ?? null, + [wallets, address] + ); + + const setActiveWallet = useCallback( + (wallet: ConnectedWallet): void => { + setWallet(wallet.address, 0); + }, + [setWallet] + ); + + const disconnectWallet = useCallback( + async (wallet: ConnectedWallet): Promise => { + setIsDisconnecting(true); + try { + await walletService.disconnectWallet(wallet.id); + setWallets((prev) => prev.filter((item) => item.id !== wallet.id)); + // Sync the shared wallet context when the removed wallet was active. + if (wallet.address === address) { + clearWalletState(); + } + return true; + } catch (err: unknown) { + const message = + err instanceof Error && err.message + ? err.message + : 'Failed to disconnect wallet'; + setError(message); + return false; + } finally { + setIsDisconnecting(false); + } + }, + [address, clearWalletState] + ); + + return { + wallets, + activeWallet, + activeAddress: address, + isConnected, + isLoading, + isDisconnecting, + error, + refetch, + disconnectWallet, + setActiveWallet, + }; +} diff --git a/services/__tests__/walletService.connectedWallets.test.ts b/services/__tests__/walletService.connectedWallets.test.ts new file mode 100644 index 0000000..c4e5fac --- /dev/null +++ b/services/__tests__/walletService.connectedWallets.test.ts @@ -0,0 +1,104 @@ +import axios from 'axios'; +import { walletService } from '@/services/walletService'; +import type { ConnectedWallet } from '@/types/wallet.types'; + +jest.mock('axios'); + +const mockAxios = axios as jest.Mocked; + +const WALLETS: ConnectedWallet[] = [ + { + id: 'w1', + address: 'GABC123DEF456STELLAR', + provider: 'freighter', + network: 'testnet', + isPrimary: true, + connectedAt: '2026-01-15T10:30:00Z', + }, + { + id: 'w2', + address: 'GDEF789GHI012STELLAR', + provider: 'ledger', + network: 'public', + isPrimary: false, + connectedAt: '2026-02-20T08:00:00Z', + }, +]; + +describe('walletService — connected wallet management', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getConnectedWallets', () => { + it('returns the wallets from the backend response', async () => { + mockAxios.get.mockResolvedValue({ data: { success: true, data: WALLETS } }); + + const result = await walletService.getConnectedWallets(); + + expect(result).toEqual(WALLETS); + const url = mockAxios.get.mock.calls[0][0] as string; + expect(url).toContain('/api/wallet/wallets'); + }); + + it('forwards the abort signal to the request', async () => { + const controller = new AbortController(); + mockAxios.get.mockResolvedValue({ data: { success: true, data: WALLETS } }); + + await walletService.getConnectedWallets(controller.signal); + + const config = mockAxios.get.mock.calls[0][1] as { signal?: AbortSignal }; + expect(config.signal).toBe(controller.signal); + }); + + it('throws the backend message when the API reports failure', async () => { + mockAxios.get.mockResolvedValue({ + data: { success: false, message: 'Unauthorized', data: [] }, + }); + + await expect(walletService.getConnectedWallets()).rejects.toThrow('Unauthorized'); + }); + + it('throws a fallback message when data is not an array', async () => { + mockAxios.get.mockResolvedValue({ data: { success: true } }); + + await expect(walletService.getConnectedWallets()).rejects.toThrow( + 'Failed to fetch connected wallets' + ); + }); + + it('propagates network errors', async () => { + mockAxios.get.mockRejectedValue(new Error('Network error')); + + await expect(walletService.getConnectedWallets()).rejects.toThrow('Network error'); + }); + }); + + describe('disconnectWallet', () => { + it('calls the wallet-specific endpoint and returns the response', async () => { + mockAxios.delete.mockResolvedValue({ + data: { success: true, message: 'Disconnected' }, + }); + + const result = await walletService.disconnectWallet('w1'); + + expect(result.success).toBe(true); + const url = mockAxios.delete.mock.calls[0][0] as string; + expect(url).toContain('/api/wallet/wallets/w1'); + }); + + it('throws the backend message when unlinking fails', async () => { + mockAxios.delete.mockResolvedValue({ + data: { success: false, message: 'Wallet not found' }, + }); + + await expect(walletService.disconnectWallet('w1')).rejects.toThrow('Wallet not found'); + }); + + it('propagates network errors', async () => { + mockAxios.delete.mockRejectedValue(new Error('Network error')); + + await expect(walletService.disconnectWallet('w1')).rejects.toThrow('Network error'); + }); + }); +}); diff --git a/services/walletService.ts b/services/walletService.ts index b2a72ff..3ea0d87 100644 --- a/services/walletService.ts +++ b/services/walletService.ts @@ -1,3 +1,14 @@ +import axios from 'axios'; +import { TransactionResponse } from '@/types/transaction'; +import type { + ConnectedWallet, + ConnectedWalletsResponse, +} from '@/types/wallet.types'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; +const STELLAR_TESTNET_EXPLORER = 'https://testnet.steexp.com/tx/'; +const STELLAR_PUBLIC_EXPLORER = 'https://steexp.com/tx/'; + export interface WalletBalance { available: number; locked: number; @@ -12,6 +23,68 @@ export interface BalanceCheckResult { requiredAmount: number; } +export interface ConnectResponse { + success: boolean; + message: string; + publicKey: string; +} + +export interface DisconnectResponse { + success: boolean; + message: string; +} + +export interface BalanceResponse { + success: boolean; + balance: number; + message?: string; +} + +export interface Signer { + publicKey: string; + weight: number; + approved: boolean; +} + +export interface PendingMultiSigOperation { + operationId: string; + transactionEnvelope: string; + description: string; + signaturesRequired: number; + currentSignatures: number; + signers: Signer[]; + createdAt: string; + status: 'pending' | 'signed' | 'rejected' | 'expired'; + expiresAt: string; +} + +export interface PendingMultiSigResponse { + success: boolean; + message: string; + operations?: PendingMultiSigOperation[]; + totalCount?: number; +} + +export interface SignMultiSigParams { + operationId: string; + signature: string; + signerPublicKey: string; +} + +export interface SignMultiSigResponse { + success: boolean; + message: string; + transactionHash?: string; + operationId?: string; + currentSignatures?: number; +} + +/** + * walletService — responsible for all wallet-related API communication. + * The hook calls this; components never call this directly. + * + * Layered Architecture: Component -> Hook -> Service + */ class WalletService { private static instance: WalletService; private balanceCache: WalletBalance | null = null; @@ -51,7 +124,7 @@ class WalletService { } const data = await response.json(); - + // Validate response structure const balance: WalletBalance = { available: data.data.available || 0, @@ -124,6 +197,117 @@ class WalletService { this.balanceCache = null; this.lastFetchTime = 0; } + + /** + * Registers the Freighter wallet session with the backend. + * The backend verifies the public key and returns a confirmed session. + */ + async connect(publicKey: string): Promise { + const { data } = await axios.post( + `${API_BASE_URL}/api/wallet/connect`, + { publicKey } + ); + return data; + } + + async disconnect(): Promise { + const { data } = await axios.post( + `${API_BASE_URL}/api/wallet/disconnect` + ); + return data; + } + + /** + * Fetch the XLM balance for the given wallet address from the backend. + * The backend is the single source of truth — no Stellar SDK calls in the browser. + */ + async getBalance(address: string): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/api/wallet/balance`, + { params: { address } } + ); + return data; + } + + /** + * Poll the backend for the transaction status. + * Returns current status and Stellar explorer URL. + * Backend queries Horizon to determine if transaction was confirmed. + */ + async getTransactionStatus(transactionHash: string): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/api/wallet/transaction/${transactionHash}` + ); + + // Append explorer URL based on network + const network = process.env.NEXT_PUBLIC_STELLAR_NETWORK || 'testnet'; + const explorerBase = network === 'public' ? STELLAR_PUBLIC_EXPLORER : STELLAR_TESTNET_EXPLORER; + + return { + ...data, + stellarExplorerUrl: `${explorerBase}${transactionHash}`, + }; + } + + /** + * Fetch pending multi-signature operations requiring the connected wallet's approval. + * The backend queries the blockchain for unsigned transactions pending this signer's approval. + */ + async getPendingMultiSigOperations( + walletAddress: string + ): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/api/wallet/multi-sig/pending`, + { params: { walletAddress } } + ); + return data; + } + + /** + * Submit a signed transaction envelope for a multi-sig operation. + * The backend verifies the signature and broadcasts the transaction if all signatures are collected. + */ + async signMultiSigOperation( + params: SignMultiSigParams + ): Promise { + const { data } = await axios.post( + `${API_BASE_URL}/api/wallet/multi-sig/sign`, + params + ); + return data; + } + + /** + * Fetch every Web3 wallet linked to the authenticated account. + * The backend owns this list — it is never synthesized on the client. + */ + async getConnectedWallets(signal?: AbortSignal): Promise { + const { data } = await axios.get( + `${API_BASE_URL}/api/wallet/wallets`, + { signal } + ); + + if (!data.success || !Array.isArray(data.data)) { + throw new Error(data.message || 'Failed to fetch connected wallets'); + } + + return data.data; + } + + /** + * Unlink a single wallet from the authenticated account. + */ + async disconnectWallet(walletId: string): Promise { + const { data } = await axios.delete( + `${API_BASE_URL}/api/wallet/wallets/${walletId}` + ); + + if (!data.success) { + throw new Error(data.message || 'Failed to disconnect wallet'); + } + + return data; + } } -export const walletService = WalletService.getInstance(); \ No newline at end of file +export const walletService = WalletService.getInstance(); diff --git a/types/wallet.types.ts b/types/wallet.types.ts index 8f799d5..29d8d9b 100644 --- a/types/wallet.types.ts +++ b/types/wallet.types.ts @@ -26,4 +26,27 @@ export interface ApiResponse { data?: T; message?: string; error?: string; +} + +/** + * A Web3 wallet linked to the authenticated account. + * Returned by the backend so the client never invents wallet state locally. + */ +export interface ConnectedWallet { + id: string; + address: string; + /** Wallet vendor, e.g. "freighter", "ledger", "walletconnect". */ + provider: string; + network: 'public' | 'testnet'; + /** True for the wallet the backend considers the account's default. */ + isPrimary: boolean; + connectedAt: string; + /** Optional user-defined nickname for the wallet. */ + label?: string; +} + +export interface ConnectedWalletsResponse { + success: boolean; + message?: string; + data: ConnectedWallet[]; } \ No newline at end of file