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 d0bf997..00e75c5 100644 --- a/services/walletService.ts +++ b/services/walletService.ts @@ -19,6 +19,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; @@ -58,7 +120,7 @@ class WalletService { } const data = await response.json(); - + // Validate response structure const balance: WalletBalance = { available: data.data.available || 0, @@ -150,6 +212,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