diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts index efe999b9..2fe84246 100644 --- a/app/api/auth/refresh/route.ts +++ b/app/api/auth/refresh/route.ts @@ -1,115 +1,115 @@ -import { NextResponse, NextRequest } from 'next/server'; -import { - generateCsrfToken, - buildCsrfCookieHeader, - buildCsrfSidCookieHeader, - buildCsrfClearCookieHeaders, - deriveCsrfBinding, - sessionKeyFromAuthToken, -} from '@/lib/utils/csrf'; - -export const runtime = 'nodejs'; - -const SESSION_SECONDS = 1800; // 30 minutes, matching GET /api/auth/session - -function apiBase(): string { - return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; -} - -/** - * POST /api/auth/refresh — exchange the current session for a fresh access - * token against the backend, then rotate the CSRF token/binding to match - * (issues #486, #487). - * - * - Backend returns a new token → set `auth_token`, return `{ ok, token }` - * so the client (axios single-flight, SessionTimeoutModal) can propagate it. - * - Backend rejects the refresh token → 401 `{ ok:false, error:'session_expired' }` - * and every session cookie is cleared, so the client redirects to login once. - * - Backend unreachable → fall back to extending the *existing* token's - * lifetime (previous behaviour) and flag `refreshed:false` so callers know - * it was not a real rotation. - */ -export async function POST(req: NextRequest) { - const existingToken = req.cookies.get('auth_token')?.value; - const role = req.cookies.get('user_role')?.value || 'merchant'; - const isProduction = process.env.NODE_ENV === 'production'; - const secure = isProduction ? '; Secure' : ''; - - if (!existingToken) { - return NextResponse.json( - { ok: false, error: 'session_expired' }, - { status: 401 }, - ); - } - - const base = apiBase(); - const isSelfLoop = - base.includes('localhost:3000') || base.includes('127.0.0.1:3000'); - - let newToken: string | null = null; - let refreshed = false; - - if (!isSelfLoop) { - try { - const upstream = await fetch(`${base}/api/auth/refresh`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - // Forward the incoming cookies so the backend sees the refresh token. - cookie: req.headers.get('cookie') ?? '', - }, - body: JSON.stringify({ token: existingToken }), - cache: 'no-store', - }); - - if (upstream.status === 401 || upstream.status === 403) { - const res = NextResponse.json( - { ok: false, error: 'session_expired' }, - { status: 401 }, - ); - res.headers.set('Set-Cookie', `auth_token=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${secure}`); - res.headers.append('Set-Cookie', `user_role=; Path=/; Max-Age=0; SameSite=Lax${secure}`); - for (const h of buildCsrfClearCookieHeaders()) res.headers.append('Set-Cookie', h); - return res; - } - - if (upstream.ok) { - const body = (await upstream.json().catch(() => ({}))) as { - token?: unknown; - accessToken?: unknown; - }; - const t = body.token ?? body.accessToken; - if (typeof t === 'string' && t.length > 0) { - newToken = t; - refreshed = true; - } - } - } catch { - // Backend offline — fall through to a local lifetime extension. - } - } - - const token = newToken ?? existingToken; - const csrfToken = generateCsrfToken(); - const csrfBinding = deriveCsrfBinding(csrfToken, sessionKeyFromAuthToken(token)); - - const res = NextResponse.json({ - ok: true, - refreshed, - token: refreshed ? token : undefined, - expiresIn: SESSION_SECONDS, - expiresAt: Date.now() + SESSION_SECONDS * 1000, - }); - - res.headers.set( - 'Set-Cookie', - `auth_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${SESSION_SECONDS}${secure}`, - ); - res.headers.append( - 'Set-Cookie', - `user_role=${role}; Path=/; SameSite=Lax; Max-Age=${SESSION_SECONDS}${secure}`, - ); - res.headers.append('Set-Cookie', buildCsrfCookieHeader(csrfToken)); - res.headers.append('Set-Cookie', buildCsrfSidCookieHeader(csrfBinding)); - return res; -} +import { NextResponse, NextRequest } from 'next/server'; +import { + generateCsrfToken, + buildCsrfCookieHeader, + buildCsrfSidCookieHeader, + buildCsrfClearCookieHeaders, + deriveCsrfBinding, + sessionKeyFromAuthToken, +} from '@/lib/utils/csrf'; + +export const runtime = 'nodejs'; + +const SESSION_SECONDS = 1800; // 30 minutes, matching GET /api/auth/session + +function apiBase(): string { + return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +} + +/** + * POST /api/auth/refresh — exchange the current session for a fresh access + * token against the backend, then rotate the CSRF token/binding to match + * (issues #486, #487). + * + * - Backend returns a new token → set `auth_token`, return `{ ok, token }` + * so the client (axios single-flight, SessionTimeoutModal) can propagate it. + * - Backend rejects the refresh token → 401 `{ ok:false, error:'session_expired' }` + * and every session cookie is cleared, so the client redirects to login once. + * - Backend unreachable → fall back to extending the *existing* token's + * lifetime (previous behaviour) and flag `refreshed:false` so callers know + * it was not a real rotation. + */ +export async function POST(req: NextRequest) { + const existingToken = req.cookies.get('auth_token')?.value; + const role = req.cookies.get('user_role')?.value || 'merchant'; + const isProduction = process.env.NODE_ENV === 'production'; + const secure = isProduction ? '; Secure' : ''; + + if (!existingToken) { + return NextResponse.json( + { ok: false, error: 'session_expired' }, + { status: 401 }, + ); + } + + const base = apiBase(); + const isSelfLoop = + base.includes('localhost:3000') || base.includes('127.0.0.1:3000'); + + let newToken: string | null = null; + let refreshed = false; + + if (!isSelfLoop) { + try { + const upstream = await fetch(`${base}/api/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Forward the incoming cookies so the backend sees the refresh token. + cookie: req.headers.get('cookie') ?? '', + }, + body: JSON.stringify({ token: existingToken }), + cache: 'no-store', + }); + + if (upstream.status === 401 || upstream.status === 403) { + const res = NextResponse.json( + { ok: false, error: 'session_expired' }, + { status: 401 }, + ); + res.headers.set('Set-Cookie', `auth_token=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${secure}`); + res.headers.append('Set-Cookie', `user_role=; Path=/; Max-Age=0; SameSite=Lax${secure}`); + for (const h of buildCsrfClearCookieHeaders()) res.headers.append('Set-Cookie', h); + return res; + } + + if (upstream.ok) { + const body = (await upstream.json().catch(() => ({}))) as { + token?: unknown; + accessToken?: unknown; + }; + const t = body.token ?? body.accessToken; + if (typeof t === 'string' && t.length > 0) { + newToken = t; + refreshed = true; + } + } + } catch { + // Backend offline — fall through to a local lifetime extension. + } + } + + const token = newToken ?? existingToken; + const csrfToken = generateCsrfToken(); + const csrfBinding = deriveCsrfBinding(csrfToken, sessionKeyFromAuthToken(token)); + + const res = NextResponse.json({ + ok: true, + refreshed, + token: refreshed ? token : undefined, + expiresIn: SESSION_SECONDS, + expiresAt: Date.now() + SESSION_SECONDS * 1000, + }); + + res.headers.set( + 'Set-Cookie', + `auth_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${SESSION_SECONDS}${secure}`, + ); + res.headers.append( + 'Set-Cookie', + `user_role=${role}; Path=/; SameSite=Lax; Max-Age=${SESSION_SECONDS}${secure}`, + ); + res.headers.append('Set-Cookie', buildCsrfCookieHeader(csrfToken)); + res.headers.append('Set-Cookie', buildCsrfSidCookieHeader(csrfBinding)); + return res; +} diff --git a/app/pay/[linkId]/page.tsx b/app/pay/[linkId]/page.tsx index 938572ab..7436f05e 100644 --- a/app/pay/[linkId]/page.tsx +++ b/app/pay/[linkId]/page.tsx @@ -27,6 +27,7 @@ import { import { signWithFreighter } from "@/lib/stellar/freighter"; import { apiClient } from "@/lib/api/axios"; import { MULTI_CURRENCY_ASSETS, MOCK_RATES, USE_MOCK_RATE_DATA } from "@/lib/utils/constants"; +import { SOROBAN_RPC_URL, SETTLEMENT_CONTRACT_ID, MERCHANT_ADDRESS, STELLAR_NETWORK_PASSPHRASE } from "@/lib/config"; import { WalletModalFallback } from "@/components/wallet/WalletModalFallback"; import { WalletModalErrorBoundary } from "@/components/wallet/WalletModalErrorBoundary"; import { QRCodeModal } from "@/components/payments/QRCode"; diff --git a/components/onboarding/OnboardingWizard.tsx b/components/onboarding/OnboardingWizard.tsx index 07e4cc48..b1095f67 100644 --- a/components/onboarding/OnboardingWizard.tsx +++ b/components/onboarding/OnboardingWizard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import Image from "next/image"; import { cn } from "@/lib/utils"; @@ -17,6 +17,7 @@ import { ChevronRight, type LucideIcon, } from "lucide-react"; +import { setOnboardingCompleted } from "@/lib/auth/session"; interface Step { title: string; @@ -37,7 +38,6 @@ const STEPS: Step[] = [ icon: Wallet, cta: { label: "Connect Wallet", - onClick: () => {}, }, }, { @@ -74,18 +74,21 @@ const STEPS: Step[] = [ export const OnboardingWizard = () => { const [currentStep, setCurrentStep] = useState(0); - const { isConnected } = useWalletStore(); - // Issue #495: gate on the shared onboarding flag (merchant_onboarded - // cookie + mirror) — the same one the /onboarding page and the middleware - // use — so finishing either surface hides this wizard on every page. + const { isConnected, setWalletModalOpen } = useWalletStore((s) => ({ + isConnected: s.isConnected, + setWalletModalOpen: s.setWalletModalOpen, + })); const { isOnboarded, hydrated, markComplete } = useOnboardingStatus(); + const visible = hydrated && !isOnboarded; + const isLastStep = currentStep === STEPS.length - 1; + const progressPercent = ((currentStep + 1) / STEPS.length) * 100; + const dismiss = useCallback(() => { + setOnboardingCompleted(true); markComplete(); }, [markComplete]); - const visible = hydrated && !isOnboarded; - const handleNext = useCallback(() => { if (currentStep < STEPS.length - 1) { setCurrentStep((s) => s + 1); @@ -96,74 +99,72 @@ export const OnboardingWizard = () => { const handleStepCta = useCallback( (step: Step) => { - if (step.cta.onClick) { - step.cta.onClick(); + if (currentStep === 0) { + setWalletModalOpen(true); } + step.cta.onClick?.(); if (currentStep < STEPS.length - 1) { setCurrentStep((s) => s + 1); } else { dismiss(); } }, - [currentStep, dismiss] + [currentStep, dismiss, setWalletModalOpen], ); + useEffect(() => { + if (!visible) { + setCurrentStep(0); + } + }, [visible]); + if (!visible) return null; - const isLastStep = currentStep === STEPS.length - 1; - const progressPercent = ((currentStep + 1) / STEPS.length) * 100; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const StepIcon = STEPS[currentStep].icon as any; + const StepIcon = STEPS[currentStep].icon; return ( -
-
+
+
-
+
-
- BettaPay Logo +
+ BettaPay Logo
- - Getting Started - + Getting Started
- + Step {currentStep + 1} of {STEPS.length}
-
+
-
- +
+
-
-

+
+

{STEPS[currentStep].title}

-

+

{STEPS[currentStep].description}

@@ -171,7 +172,7 @@ export const OnboardingWizard = () => { ) : ( @@ -181,44 +182,41 @@ export const OnboardingWizard = () => { onClick={() => handleStepCta(STEPS[currentStep])} > {isConnected ? "Connected" : STEPS[currentStep].cta.label} - {!isConnected && } + {!isConnected && } )} {!isLastStep && ( - )}

-
+
{STEPS.map((_, i) => ( diff --git a/components/shared/PageTransition.tsx b/components/shared/PageTransition.tsx index 66c7726c..362ee0ad 100644 --- a/components/shared/PageTransition.tsx +++ b/components/shared/PageTransition.tsx @@ -1,49 +1,44 @@ "use client"; -import React from 'react'; +import { useEffect, useRef, type ReactNode } from 'react'; interface PageTransitionProps { - children: React.ReactNode; - routingKey?: string; // The active route path (e.g., location.pathname or router.asPath) + children: ReactNode; + routingKey?: string; } -// Global dictionary cache to store viewport depths across client-side navigation const scrollCoordinateCache: Record = {}; export function PageTransition({ children, routingKey = '' }: PageTransitionProps) { - const containerRef = React.useRef(null); + const containerRef = useRef(null); - // Capture scroll coordinates immediately prior to unmounting the current active route - React.useEffect(() => { + useEffect(() => { return () => { if (typeof window !== 'undefined') { - scrollCoordinateCache[routingKey] = window.scrollY || document.documentElement.scrollTop; + scrollCoordinateCache[routingKey] = + window.scrollY || document.documentElement.scrollTop; } }; }, [routingKey]); - // Restore cached scroll position the millisecond the new page route settles - React.useEffect(() => { + useEffect(() => { if (typeof window !== 'undefined') { const targetScrollDepth = scrollCoordinateCache[routingKey] || 0; - - // Execute an instantaneous jump to eliminate jumpy layout bounce or refetch flashes window.scrollTo({ top: targetScrollDepth, - behavior: 'auto' + behavior: 'auto', }); } }, [routingKey]); - // Acceptance Criteria: Persistent page shell structure animating ONLY inner content opacity return ( -
{children} diff --git a/components/status/ComponentStatus.tsx b/components/status/ComponentStatus.tsx index fc50bbb6..0add63af 100644 --- a/components/status/ComponentStatus.tsx +++ b/components/status/ComponentStatus.tsx @@ -1,6 +1,7 @@ "use client"; -import { CheckCircle2, AlertTriangle, XCircle, HelpCircle, type LucideIcon } from "lucide-react"; +import { CheckCircle2, AlertTriangle, XCircle, type LucideIcon } from "lucide-react"; +import { HelpCircle } from "lucide-react"; import type { ComponentStatusLevel, StatusComponent } from "@/lib/status/data"; import { formatRelativeTime } from "@/lib/status/time"; import { STATUS_TONE_BADGE, STATUS_TONE_DOT, type StatusTone } from "@/lib/status/palette"; diff --git a/components/status/OverallBanner.tsx b/components/status/OverallBanner.tsx index a1248fd6..a7fe1bf8 100644 --- a/components/status/OverallBanner.tsx +++ b/components/status/OverallBanner.tsx @@ -1,6 +1,7 @@ "use client"; -import { CheckCircle2, AlertTriangle, XCircle, HelpCircle, type LucideIcon } from "lucide-react"; +import { CheckCircle2, AlertTriangle, XCircle, type LucideIcon } from "lucide-react"; +import { HelpCircle } from "lucide-react"; import type { ComponentStatusLevel } from "@/lib/status/data"; import { STATUS_TONE_BADGE, STATUS_TONE_DOT, STATUS_TONE_TEXT, type StatusTone } from "@/lib/status/palette"; import { useNow } from "@/lib/hooks/useNow"; diff --git a/components/wallet/WalletConnectModal.tsx b/components/wallet/WalletConnectModal.tsx index 0b6165de..84e28ac8 100644 --- a/components/wallet/WalletConnectModal.tsx +++ b/components/wallet/WalletConnectModal.tsx @@ -8,12 +8,13 @@ import { Button } from '@/components/ui'; import { getWalletConnectClient, resetWalletConnectClient, +} from '@/lib/stellar/walletconnect'; +import type { + StellarWalletConnectNetwork, WalletConnectStatus, WalletConnectSession, } from '@/lib/stellar/walletconnect'; -// ─── Copy-to-clipboard helper ───────────────────────────────────────────────── - function useCopyUri(uri: string) { const [copied, setCopied] = useState(false); const copy = useCallback(async () => { @@ -28,8 +29,6 @@ function useCopyUri(uri: string) { return { copied, copy }; } -// ─── Status copy map ────────────────────────────────────────────────────────── - const STATUS_LABEL: Record = { idle: '', connecting: 'Waiting for wallet to scan…', @@ -41,20 +40,17 @@ const STATUS_LABEL: Record = { error: 'Connection failed', }; -// ─── Props ──────────────────────────────────────────────────────────────────── - interface WalletConnectModalProps { open: boolean; onOpenChange: (v: boolean) => void; - /** Called with the Stellar G-address once the session is established */ + network: StellarWalletConnectNetwork; onConnected: (session: WalletConnectSession) => void; } -// ─── Component ──────────────────────────────────────────────────────────────── - export function WalletConnectModal({ open, onOpenChange, + network, onConnected, }: WalletConnectModalProps) { const [uri, setUri] = useState(''); @@ -63,36 +59,34 @@ export function WalletConnectModal({ const [statusDetail, setStatusDetail] = useState(''); const { copied, copy } = useCopyUri(uri); - // Track whether this modal instance started the connection so we don't - // attempt to start it twice on Strict Mode double-mount. const startedRef = useRef(false); - // Guard against stale session callbacks after the modal is closed. + const startedNetworkRef = useRef(null); const closedRef = useRef(false); const startConnection = useCallback(async () => { + const activeNetwork = network; startedRef.current = true; + startedNetworkRef.current = activeNetwork; closedRef.current = false; setUri(''); setErrorMsg(''); setStatusDetail(''); setStatus('idle'); - // Always get a fresh client so keys/topics are rotated resetWalletConnectClient(); - const client = getWalletConnectClient(); + const client = getWalletConnectClient(activeNetwork); client.onStatus((s, detail) => { - if (closedRef.current) return; + if (closedRef.current || startedNetworkRef.current !== activeNetwork) return; setStatus(s); setStatusDetail(detail ?? ''); if (s === 'error') setErrorMsg(detail ?? 'Unknown error'); }); client.onSession((session) => { - if (closedRef.current) return; - // Brief pause so the user sees the "connected" tick before the modal closes + if (closedRef.current || startedNetworkRef.current !== activeNetwork) return; setTimeout(() => { - if (closedRef.current) return; + if (closedRef.current || startedNetworkRef.current !== activeNetwork) return; onOpenChange(false); onConnected(session); }, 800); @@ -100,26 +94,27 @@ export function WalletConnectModal({ try { const wcUri = await client.connect(); + if (closedRef.current || startedNetworkRef.current !== activeNetwork) return; setUri(wcUri); } catch (err) { + if (closedRef.current || startedNetworkRef.current !== activeNetwork) return; setStatus('error'); setErrorMsg(err instanceof Error ? err.message : 'Failed to start WalletConnect'); } - }, [onOpenChange, onConnected]); + }, [network, onOpenChange, onConnected]); - // Start a connection whenever the modal opens useEffect(() => { if (!open) { closedRef.current = true; startedRef.current = false; + startedNetworkRef.current = null; return; } closedRef.current = false; - if (startedRef.current) return; + if (startedRef.current && startedNetworkRef.current === network) return; void startConnection(); - }, [open, startConnection]); + }, [open, network, startConnection]); - // Tear down the WebSocket when the modal is closed without completing const handleOpenChange = useCallback( (v: boolean) => { if (!v) { @@ -130,14 +125,13 @@ export function WalletConnectModal({ setErrorMsg(''); setStatusDetail(''); startedRef.current = false; + startedNetworkRef.current = null; } onOpenChange(v); }, [onOpenChange], ); - // ── Render ────────────────────────────────────────────────────────────────── - const showQr = uri && status !== 'connected' && @@ -160,7 +154,6 @@ export function WalletConnectModal({
- {/* QR code */} {showQr && (

@@ -181,7 +174,6 @@ export function WalletConnectModal({ />

- {/* Copy URI button */} -
); diff --git a/components/wallet/WalletModal.tsx b/components/wallet/WalletModal.tsx index 7a34aa00..86dafdeb 100644 --- a/components/wallet/WalletModal.tsx +++ b/components/wallet/WalletModal.tsx @@ -1,9 +1,11 @@ "use client"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; +import { X } from "lucide-react"; import { useWalletStore } from "@/lib/store/walletStore"; +import { WalletConnectModal } from "./WalletConnectModal"; import { WalletModalErrorBoundary } from "./WalletModalErrorBoundary"; -import { X } from "lucide-react"; +import type { WalletConnectSession } from "@/lib/stellar/walletconnect"; export interface WalletModalProps { isOpen?: boolean; @@ -155,13 +157,17 @@ function WalletConnectOptions() { ); } -export function WalletModal({ isOpen, onClose, onConnected }: WalletModalProps) { +export function WalletModal({ isOpen = true, onClose, onConnected }: WalletModalProps) { const walletModalOpen = useWalletStore((s) => s.walletModalOpen); + const walletConnectPending = useWalletStore((s) => s.walletConnectPending); + const network = useWalletStore((s) => s.network); const setWalletModalOpen = useWalletStore((s) => s.setWalletModalOpen); + const cancelWalletConnect = useWalletStore((s) => s.cancelWalletConnect); + const resolveWalletConnect = useWalletStore((s) => s.resolveWalletConnect); const address = useWalletStore((s) => s.address); useEffect(() => { - if (isOpen !== undefined && isOpen !== walletModalOpen) { + if (isOpen !== walletModalOpen) { setWalletModalOpen(isOpen); } }, [isOpen, walletModalOpen, setWalletModalOpen]); @@ -174,47 +180,66 @@ export function WalletModal({ isOpen, onClose, onConnected }: WalletModalProps) const handleClose = () => { setWalletModalOpen(false); - if (onClose) onClose(); + onClose?.(); + }; + + const handleWalletConnectSession = (session: WalletConnectSession) => { + resolveWalletConnect(session); + }; + + const handleWalletConnectOpenChange = (open: boolean) => { + if (!open) { + cancelWalletConnect(); + } }; if (!isOpen) return null; return ( -
-
-
-

- Connect Wallet -

- -
+ <> +
+
+
+

+ Connect Wallet +

+ +
-
-

- Select a secure provider endpoint to synchronize your ledger state. -

+
+

+ Select a secure provider endpoint to synchronize your ledger state. +

- {}}> - - -
+ {}}> + + +
-
- +
+ +
-
+ + + ); } diff --git a/components/wallet/WalletModalErrorBoundary.tsx b/components/wallet/WalletModalErrorBoundary.tsx index 35cbbf45..7a7f752d 100644 --- a/components/wallet/WalletModalErrorBoundary.tsx +++ b/components/wallet/WalletModalErrorBoundary.tsx @@ -1,4 +1,6 @@ -import React, { Component, ReactNode } from 'react'; +"use client"; + +import { Component, type ErrorInfo, type ReactNode } from 'react'; interface WalletModalErrorBoundaryProps { children: ReactNode; @@ -25,33 +27,31 @@ export class WalletModalErrorBoundary extends Component< return { hasError: true, error }; } - componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { - console.error("WalletModalErrorBoundary caught an error:", error, errorInfo); + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error('WalletModalErrorBoundary caught an error:', error, errorInfo); } handleRetry = (): void => { this.setState({ hasError: false, error: null }); - if (this.props.onRetry) { - this.props.onRetry(); - } + this.props.onRetry?.(); }; render() { if (this.state.hasError) { return ( -
-

+

+

Failed to connect wallet or load session.

{this.state.error?.message && ( -

+

{this.state.error.message}

)} diff --git a/components/wallet/__tests__/WalletConnectModal.test.tsx b/components/wallet/__tests__/WalletConnectModal.test.tsx new file mode 100644 index 00000000..fdfa9731 --- /dev/null +++ b/components/wallet/__tests__/WalletConnectModal.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { WalletConnectModal } from '../WalletConnectModal'; + +const connect = jest.fn().mockResolvedValue('wc:test-uri'); +const onStatus = jest.fn(); +const onSession = jest.fn(); +const resetWalletConnectClient = jest.fn(); +const getWalletConnectClient = jest.fn(() => ({ + connect, + onStatus, + onSession, +})); + +jest.mock('@/lib/stellar/walletconnect', () => ({ + getWalletConnectClient: (...args: unknown[]) => getWalletConnectClient(...args), + resetWalletConnectClient: () => resetWalletConnectClient(), +})); + +jest.mock('@/components/ui', () => ({ + Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})); + +jest.mock('qrcode.react', () => ({ + QRCodeSVG: ({ + includeMargin: _includeMargin, + ...props + }: React.SVGProps & { includeMargin?: boolean }) => ( + + ), +})); + +describe('WalletConnectModal network wiring', () => { + beforeEach(() => { + jest.clearAllMocks(); + connect.mockResolvedValue('wc:test-uri'); + }); + + it('rebuilds the pairing client with the active wallet network', async () => { + const { rerender } = render( + , + ); + + await waitFor(() => expect(getWalletConnectClient).toHaveBeenCalledWith('testnet')); + + rerender( + , + ); + + rerender( + , + ); + + await waitFor(() => expect(getWalletConnectClient).toHaveBeenCalledWith('public')); + + expect(getWalletConnectClient.mock.calls.map(([network]) => network)).toEqual([ + 'testnet', + 'public', + ]); + }); +}); diff --git a/lib/health/checkers.ts b/lib/health/checkers.ts index 6207f0b4..34ebcc54 100644 --- a/lib/health/checkers.ts +++ b/lib/health/checkers.ts @@ -12,7 +12,7 @@ import type { ServiceHealth } from "@/lib/types/health"; import type { AnchorHealth, AnchorHealthStatus } from "@/lib/types"; -import { HORIZON_URL, SOROBAN_RPC_URL, ANCHOR_URL, API_URL } from "@/lib/config"; +import { API_URL, HORIZON_URL, SOROBAN_RPC_URL, ANCHOR_URL } from "@/lib/config"; // --------------------------------------------------------------------------- // Internal helpers diff --git a/lib/stellar/__tests__/walletconnect.test.ts b/lib/stellar/__tests__/walletconnect.test.ts index ac798ce6..d4d4602a 100644 --- a/lib/stellar/__tests__/walletconnect.test.ts +++ b/lib/stellar/__tests__/walletconnect.test.ts @@ -19,6 +19,9 @@ if (!(globalThis.crypto && 'subtle' in globalThis.crypto)) { import { WalletConnectClient, WalletConnectTimeoutError, + getStellarWalletConnectChain, + getWalletConnectClient, + resetWalletConnectClient, type WalletConnectStatus, } from '@/lib/stellar/walletconnect'; @@ -82,9 +85,9 @@ const factory = (url: string) => const latest = () => MockRelaySocket.instances[MockRelaySocket.instances.length - 1]; -function makeClient() { +function makeClient(chainId: 'stellar:testnet' | 'stellar:pubnet' = 'stellar:testnet') { const statuses: Array<[WalletConnectStatus, string | undefined]> = []; - const client = new WalletConnectClient(factory); + const client = new WalletConnectClient(factory, chainId); client.onStatus((s, d) => statuses.push([s, d])); return { client, statuses }; } @@ -104,6 +107,7 @@ afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); jest.restoreAllMocks(); + resetWalletConnectClient(); }); // ─── Reconnect ─────────────────────────────────────────────────────────────── @@ -228,6 +232,50 @@ describe('relay heartbeat', () => { // ─── Phase timeouts ────────────────────────────────────────────────────────── +describe('chain negotiation', () => { + it('maps the UI network to the matching chain id', () => { + expect(getStellarWalletConnectChain('testnet')).toBe('stellar:testnet'); + expect(getStellarWalletConnectChain('public')).toBe('stellar:pubnet'); + expect(getStellarWalletConnectChain('mainnet')).toBe('stellar:pubnet'); + }); + + it('keeps the active chain when callers reuse the singleton without a network override', () => { + const client = getWalletConnectClient('public'); + expect(client.chainId).toBe('stellar:pubnet'); + expect(getWalletConnectClient()).toBe(client); + }); + + it('advertises pubnet and rejects a mismatched wallet session', async () => { + const { client, statuses } = makeClient('stellar:pubnet'); + const uri = await client.connect(); + const sock = latest(); + sock.accept(); + + await drivePairing(client, sock, uri, { + settleAccounts: [`stellar:testnet:G${'A'.repeat(55)}`], + }); + + expect(lastStatus(statuses)).toBe('error'); + const detail = statuses[statuses.length - 1][1] ?? ''; + expect(detail).toMatch(/reported stellar:testnet/i); + + const publishes = sock + .parsedSent() + .filter((m) => m.method === 'irn_publish') + .map((m) => m.params as { topic: string; message: string }); + + const pairingTopic = uri.slice(3, uri.indexOf('@')); + const symKeyHex = uri.match(/symKey=([0-9a-f]+)/)![1]; + const pairingKey = await importAesKey(symKeyHex); + const ackPublish = publishes.find((p) => p.topic === pairingTopic)!; + const ack = JSON.parse(await open(ackPublish.message, pairingKey)) as { result: { responderPublicKey: string } }; + const sessionKey = await importAesKey(ack.result.responderPublicKey); + const sessionPublish = publishes.find((p) => p.topic !== pairingTopic)!; + const settle = JSON.parse(await open(sessionPublish.message, sessionKey)) as { params: { namespaces: { stellar: { chains: string[] } } } }; + expect(settle.params.namespaces.stellar.chains).toEqual(['stellar:pubnet']); + }); +}); + describe('phase timeouts', () => { it('aborts pairing with a typed timeout error if no wallet connects', async () => { const { client, statuses } = makeClient(); @@ -349,6 +397,7 @@ async function drivePairing( client: WalletConnectClient, sock: MockRelaySocket, uri: string, + options?: { settleAccounts?: string[] }, ) { const pairingTopic = uri.slice(3, uri.indexOf('@')); const symKeyHex = uri.match(/symKey=([0-9a-f]+)/)![1]; @@ -392,7 +441,7 @@ async function drivePairing( method: 'wc_sessionSettle', params: { namespaces: { - stellar: { accounts: [`stellar:testnet:G${'A'.repeat(55)}`] }, + stellar: { accounts: options?.settleAccounts ?? [`stellar:testnet:G${'A'.repeat(55)}`] }, }, controller: { metadata: { name: 'Test Wallet', description: '', url: '', icons: [] }, diff --git a/lib/stellar/walletconnect.ts b/lib/stellar/walletconnect.ts index f47c24a1..ea5ef514 100644 --- a/lib/stellar/walletconnect.ts +++ b/lib/stellar/walletconnect.ts @@ -33,8 +33,37 @@ const RELAY_URL = WALLETCONNECT_RELAY_URL; const PROJECT_ID = WALLETCONNECT_PROJECT_ID; -/** CAIP-2 chain identifier for Stellar */ -const STELLAR_CHAIN = 'stellar:testnet'; +export type StellarWalletConnectNetwork = 'testnet' | 'public'; +export type StellarWalletConnectChainId = + typeof STELLAR_TESTNET_CHAIN | typeof STELLAR_PUBNET_CHAIN; + +/** CAIP-2 chain identifiers for Stellar. */ +export const STELLAR_TESTNET_CHAIN = 'stellar:testnet'; +export const STELLAR_PUBNET_CHAIN = 'stellar:pubnet'; + +/** Maps the UI wallet network to the CAIP-2 Stellar chain used by WalletConnect. */ +export const STELLAR_WALLETCONNECT_CHAIN_BY_NETWORK: Record< + StellarWalletConnectNetwork, + StellarWalletConnectChainId +> = { + testnet: STELLAR_TESTNET_CHAIN, + public: STELLAR_PUBNET_CHAIN, +}; + +export function normalizeWalletNetwork( + network: string = 'testnet', +): StellarWalletConnectNetwork { + const normalized = network.toLowerCase().trim(); + return normalized === 'public' || normalized === 'mainnet' || normalized === 'pubnet' + ? 'public' + : 'testnet'; +} + +export function getStellarWalletConnectChain( + network: string = 'testnet', +): StellarWalletConnectChainId { + return STELLAR_WALLETCONNECT_CHAIN_BY_NETWORK[normalizeWalletNetwork(network)]; +} /** WalletConnect relay JSON-RPC method */ const RELAY_PUBLISH = 'irn_publish'; @@ -158,6 +187,23 @@ export class WalletConnectConnectionError extends WalletConnectError { } } +export class WalletConnectNetworkMismatchError extends WalletConnectError { + readonly expectedChainId: StellarWalletConnectChainId; + readonly reportedChainIds: StellarWalletConnectChainId[]; + + constructor(expectedChainId: StellarWalletConnectChainId, reportedChainIds: StellarWalletConnectChainId[]) { + const reportedLabel = + reportedChainIds.length > 0 ? reportedChainIds.join(', ') : 'no Stellar network'; + super( + `WalletConnect session reported ${reportedLabel}, but the UI is set to ${expectedChainId}.`, + 'approving', + ); + this.name = 'WalletConnectNetworkMismatchError'; + this.expectedChainId = expectedChainId; + this.reportedChainIds = reportedChainIds; + } +} + // ─── Crypto helpers ─────────────────────────────────────────────────────────── async function generateSymKey(): Promise { @@ -201,6 +247,17 @@ async function decrypt(envelope: WCEncryptedEnvelope, key: CryptoKey): Promise WebSocket = (url) => new WebSocket(url), + public readonly chainId: StellarWalletConnectChainId = + getStellarWalletConnectChain(), ) {} // ── Public API ────────────────────────────────────────────────────────────── @@ -745,7 +804,7 @@ export class WalletConnectClient { accounts: [], methods: [METHOD_STELLAR_SIGN_TX, METHOD_STELLAR_SIGN_MSG], events: [], - chains: [STELLAR_CHAIN], + chains: [this.chainId], }, }, expiry: Math.floor(Date.now() / 1000) + 7 * 24 * 3600, @@ -784,6 +843,7 @@ export class WalletConnectClient { namespaces: { stellar?: { accounts: string[]; // "stellar:testnet:G..." + chains?: string[]; }; }; controller: { metadata: WCPeerMetadata }; @@ -794,20 +854,42 @@ export class WalletConnectClient { const stellarNS = settle?.namespaces?.stellar; const rawAccounts: string[] = stellarNS?.accounts ?? []; + const reportedChains = Array.from( + new Set( + [ + ...(stellarNS?.chains ?? []).map((chain) => getStellarWalletConnectChain(chain)), + ...rawAccounts + .map((account) => getStellarChainFromAccount(account)) + .filter((chain): chain is StellarWalletConnectChainId => Boolean(chain)), + ], + ), + ); - // Extract and validate Stellar addresses from CAIP-2 format - const stellarAccounts = extractValidStellarAddresses(rawAccounts); + if (reportedChains.length === 0 || !reportedChains.includes(this.chainId)) { + this.failWith( + new WalletConnectNetworkMismatchError(this.chainId, reportedChains), + ); + return; + } + + const matchingAccounts = rawAccounts.filter((account) => { + const accountChain = getStellarChainFromAccount(account); + return !accountChain || accountChain === this.chainId; + }); + + const stellarAccounts = extractValidStellarAddresses(matchingAccounts); if (stellarAccounts.length === 0) { this.failWith( new WalletConnectError( - 'No Stellar accounts found in the WalletConnect session.', + 'No Stellar accounts found in the WalletConnect session for the selected network.', 'approving', ), ); return; } + // Extract and validate Stellar addresses from CAIP-2 format const session: WalletConnectSession = { topic: this.sessionTopic, peerMetadata: settle.controller?.metadata ?? { @@ -848,7 +930,7 @@ export class WalletConnectClient { method: METHOD_SESSION_REQUEST, params: { request: { method, params }, - chainId: STELLAR_CHAIN, + chainId: this.chainId, }, }; @@ -965,11 +1047,20 @@ export class WalletConnectClient { // One client instance per browser page — avoids multiple open WebSockets. let _client: WalletConnectClient | null = null; -export function getWalletConnectClient(): WalletConnectClient { +export function getWalletConnectClient( + network?: StellarWalletConnectNetwork | string, +): WalletConnectClient { if (typeof window === 'undefined') { throw new Error('WalletConnectClient is only available in the browser'); } - if (!_client) _client = new WalletConnectClient(); + if (_client) { + if (network === undefined || _client.chainId === getStellarWalletConnectChain(network)) { + return _client; + } + _client?.disconnect(); + } + const chainId = getStellarWalletConnectChain(network); + _client = new WalletConnectClient(undefined, chainId); return _client; } diff --git a/lib/store/walletStore.ts b/lib/store/walletStore.ts index 8bde6390..1646c8d2 100644 --- a/lib/store/walletStore.ts +++ b/lib/store/walletStore.ts @@ -1,316 +1,322 @@ -import { create } from 'zustand'; -import { AssetBalance } from '../types'; -import { connectFreighter, FreighterNotInstalledError, FreighterCancelledError, FreighterNetworkMismatchError } from '@/lib/stellar/freighter'; -import { getWalletConnectClient, resetWalletConnectClient, WalletConnectSession } from '@/lib/stellar/walletconnect'; -import { retryWithBackoff } from '../utils/retry'; -import { setWalletContextProvider } from '../errorReporting/context'; -import { captureException } from '../errorReporting'; - -type Connector = 'freighter' | 'walletconnect' | null; - -const NETWORK_URLS: Record = { - testnet: 'https://horizon-testnet.stellar.org', - public: 'https://horizon.stellar.org', -}; - -function getNetwork(): 'testnet' | 'public' { - const val = (process.env.NEXT_PUBLIC_STELLAR_NETWORK || 'testnet').toLowerCase(); - if (val === 'mainnet' || val === 'public') return 'public'; - return 'testnet'; -} - -interface ConnectError { - type: 'not_installed' | 'cancelled' | 'network_mismatch' | 'generic'; - message: string; - raw?: string; - expectedNetwork?: string; - freighterNetwork?: string; -} - -export interface WalletState { - address: string | null; - stellarAccounts: string[]; - isConnected: boolean; - connector: Connector; - network: 'testnet' | 'public'; - balances: AssetBalance[]; - loading: boolean; - isReconnecting: boolean; - error: string | null; - connectError: ConnectError | null; - - // ── WalletConnect ────────────────────────────────────────────────────────── - /** Resolves when a WalletConnect session is established. Set by the store so - * WalletModal can trigger the QR flow imperatively and await its result. */ - walletConnectPending: boolean; - /** Stores the active session for later signing calls. */ - walletConnectSession: WalletConnectSession | null; - - // ── WalletModal State ────────────────────────────────────────────────────── - walletModalOpen: boolean; - setWalletModalOpen: (open: boolean) => void; - - connect: (connector?: Connector) => Promise; - /** Called by WalletConnectModal once a session is fully established. */ - resolveWalletConnect: (session: WalletConnectSession) => void; - selectAccount: (address: string) => void; - disconnect: () => void; - clearConnectError: () => void; - setNetwork: (network: 'testnet' | 'public') => void; - refreshBalances: () => Promise; - /** Sign a transaction XDR via whichever connector is active. */ - signTransaction: (xdr: string) => Promise; - /** Sign a plaintext message/challenge via whichever connector is active. */ - signMessage: (message: string) => Promise; -} - -export const useWalletStore = create((set, get) => ({ - address: null, - stellarAccounts: [], - isConnected: false, - connector: null, - network: getNetwork(), - balances: [], - loading: false, - isReconnecting: false, - error: null, - connectError: null, - walletModalOpen: false, - walletConnectPending: false, - walletConnectSession: null, - - connect: async (connector: Connector = 'freighter') => { - try { - set({ connectError: null }); - - if (connector === 'freighter') { - const address = await connectFreighter(); - if (address) { - set({ address, stellarAccounts: [address], isConnected: true, connector: 'freighter', connectError: null }); - get().refreshBalances(); - } else { - throw new Error('Freighter connection failed'); - } - return; - } - - if (connector === 'walletconnect') { - // Signal to WalletModal that it should open the WalletConnectModal. - // The modal calls resolveWalletConnect() once the session is live. - set({ walletConnectPending: true }); - // connect() returns here; the actual address is set via resolveWalletConnect. - return; - } - - throw new Error('Unsupported connector'); - } catch (error) { - console.error('Failed to connect wallet', error); - captureException(error, { source: 'wallet' }); - - if (error instanceof FreighterNotInstalledError) { - set({ connectError: { type: 'not_installed', message: error.message } }); - } else if (error instanceof FreighterCancelledError) { - set({ connectError: { type: 'cancelled', message: error.message } }); - } else if (error instanceof FreighterNetworkMismatchError) { - set({ - connectError: { - type: 'network_mismatch', - message: error.message, - expectedNetwork: error.expectedNetwork, - freighterNetwork: error.freighterNetwork, - }, - }); - } else { - set({ - connectError: { - type: 'generic', - message: error instanceof Error ? error.message : 'An unexpected error occurred', - raw: String(error), - }, - }); - } - - throw error; - } - }, - - resolveWalletConnect: (session: WalletConnectSession) => { - const stellarAccounts = session.stellarAccounts && session.stellarAccounts.length > 0 - ? session.stellarAccounts - : session.address ? [session.address] : []; - const selectedAddress = session.address && stellarAccounts.includes(session.address) - ? session.address - : stellarAccounts[0] || null; - - set({ - address: selectedAddress, - stellarAccounts, - isConnected: true, - connector: 'walletconnect', - connectError: null, - walletConnectPending: false, - walletConnectSession: { - ...session, - address: selectedAddress || session.address, - stellarAccounts, - }, - }); - get().refreshBalances(); - }, - - selectAccount: (address: string) => { - const { stellarAccounts, address: currentAddress } = get(); - if (!address || address === currentAddress) return; - if (stellarAccounts.length > 0 && !stellarAccounts.includes(address)) return; - - set({ address, balances: [], loading: true, error: null }); - get().refreshBalances(); - }, - - disconnect: () => { - // Clean up WalletConnect WebSocket if it was the active connector - if (get().connector === 'walletconnect') { - resetWalletConnectClient(); - } - set({ - address: null, - stellarAccounts: [], - isConnected: false, - connector: null, - balances: [], - loading: false, - isReconnecting: false, - error: null, - connectError: null, - walletConnectPending: false, - walletConnectSession: null, - }); - }, - - clearConnectError: () => { - set({ connectError: null }); - }, - - setWalletModalOpen: (open: boolean) => { - if (!open) { - set({ walletModalOpen: false, connectError: null, walletConnectPending: false }); - } else { - set({ walletModalOpen: true }); - } - }, - - setNetwork: (network: 'testnet' | 'public') => { - const current = get().network; - if (current === network) return; - set({ network, balances: [], loading: true, error: null }); - get().refreshBalances(); - }, - - signTransaction: async (xdr: string): Promise => { - const { connector } = get(); - - if (connector === 'freighter') { - const { signWithFreighter } = await import('@/lib/stellar/freighter'); - const signed = await signWithFreighter(xdr); - if (!signed) throw new Error('Freighter rejected the transaction'); - return signed; - } - - if (connector === 'walletconnect') { - const client = getWalletConnectClient(); - return client.signTransaction(xdr); - } - - throw new Error('No wallet connected'); - }, - - signMessage: async (message: string): Promise => { - const { connector, address } = get(); - - if (connector === 'freighter') { - const { signChallenge } = await import('@/lib/stellar/freighter'); - const sig = await signChallenge(address!, message); - if (!sig) throw new Error('Freighter rejected signing the message'); - return sig; - } - - if (connector === 'walletconnect') { - const client = getWalletConnectClient(); - return client.signMessage(message, address!); - } - - throw new Error('No wallet connected'); - }, - - refreshBalances: async () => { - const { address, network } = get(); - if (!address) return; - - set({ loading: true, error: null, isReconnecting: false }); - - const horizonUrl = NETWORK_URLS[network]; - - try { - const result = await retryWithBackoff( - async () => { - const response = await fetch(`${horizonUrl}/accounts/${address}`); - - if (!response.ok) { - if (response.status === 404) return 'NOT_FOUND' as const; - throw new Error(`Horizon error: ${response.status} ${response.statusText}`); - } - - return await response.json(); - }, - { - maxRetries: 3, - baseDelay: 500, - maxDelay: 3000, - isRetryable: () => true, - onRetry: (_err, attempt) => { - set({ isReconnecting: true }); - }, - }, - ); - - set({ isReconnecting: false }); - - if (result === 'NOT_FOUND') { - set({ balances: [], loading: false }); - return; - } - - const data = result as { - balances: Array<{ - asset_type: string; - balance: string; - asset_code?: string; - asset_issuer?: string; - }>; - }; - - const balances: AssetBalance[] = data.balances.map((b) => { - if (b.asset_type === 'native') return { assetCode: 'XLM', balance: b.balance }; - return { assetCode: b.asset_code!, balance: b.balance, assetIssuer: b.asset_issuer }; - }); - - set({ balances, loading: false, error: null }); - } catch (error) { - console.error('Failed to refresh balances', error); - captureException(error, { source: 'wallet' }); - set({ - loading: false, - isReconnecting: false, - error: error instanceof Error ? error.message : 'Failed to fetch balances', - }); - } - }, -})); - -// Let error reports carry wallet context. Registered here rather than imported -// by the reporting module so the Stellar SDK is only pulled into bundles that -// actually use the wallet. The address is deliberately never exposed. -setWalletContextProvider(() => { - const { isConnected, connector, network } = useWalletStore.getState(); - return { connected: isConnected, connector, network }; -}); +import { create } from 'zustand'; +import { AssetBalance } from '../types'; +import { connectFreighter, FreighterNotInstalledError, FreighterCancelledError, FreighterNetworkMismatchError } from '@/lib/stellar/freighter'; +import { getWalletConnectClient, resetWalletConnectClient, WalletConnectSession } from '@/lib/stellar/walletconnect'; +import { retryWithBackoff } from '../utils/retry'; +import { setWalletContextProvider } from '../errorReporting/context'; +import { captureException } from '../errorReporting'; + +type Connector = 'freighter' | 'walletconnect' | null; + +const NETWORK_URLS: Record = { + testnet: 'https://horizon-testnet.stellar.org', + public: 'https://horizon.stellar.org', +}; + +function getNetwork(): 'testnet' | 'public' { + const val = (process.env.NEXT_PUBLIC_STELLAR_NETWORK || 'testnet').toLowerCase(); + if (val === 'mainnet' || val === 'public') return 'public'; + return 'testnet'; +} + +interface ConnectError { + type: 'not_installed' | 'cancelled' | 'network_mismatch' | 'generic'; + message: string; + raw?: string; + expectedNetwork?: string; + freighterNetwork?: string; +} + +export interface WalletState { + address: string | null; + stellarAccounts: string[]; + isConnected: boolean; + connector: Connector; + network: 'testnet' | 'public'; + balances: AssetBalance[]; + loading: boolean; + isReconnecting: boolean; + error: string | null; + connectError: ConnectError | null; + + // ── WalletConnect ────────────────────────────────────────────────────────── + /** Resolves when a WalletConnect session is established. Set by the store so + * WalletModal can trigger the QR flow imperatively and await its result. */ + walletConnectPending: boolean; + /** Stores the active session for later signing calls. */ + walletConnectSession: WalletConnectSession | null; + + // ── WalletModal State ────────────────────────────────────────────────────── + walletModalOpen: boolean; + setWalletModalOpen: (open: boolean) => void; + + connect: (connector?: Connector) => Promise; + /** Called by WalletConnectModal once a session is fully established. */ + resolveWalletConnect: (session: WalletConnectSession) => void; + /** Clears the pending WalletConnect QR flow without disconnecting a live wallet. */ + cancelWalletConnect: () => void; + selectAccount: (address: string) => void; + disconnect: () => void; + clearConnectError: () => void; + setNetwork: (network: 'testnet' | 'public') => void; + refreshBalances: () => Promise; + /** Sign a transaction XDR via whichever connector is active. */ + signTransaction: (xdr: string) => Promise; + /** Sign a plaintext message/challenge via whichever connector is active. */ + signMessage: (message: string) => Promise; +} + +export const useWalletStore = create((set, get) => ({ + address: null, + stellarAccounts: [], + isConnected: false, + connector: null, + network: getNetwork(), + balances: [], + loading: false, + isReconnecting: false, + error: null, + connectError: null, + walletModalOpen: false, + walletConnectPending: false, + walletConnectSession: null, + + connect: async (connector: Connector = 'freighter') => { + try { + set({ connectError: null }); + + if (connector === 'freighter') { + const address = await connectFreighter(); + if (address) { + set({ address, stellarAccounts: [address], isConnected: true, connector: 'freighter', connectError: null }); + get().refreshBalances(); + } else { + throw new Error('Freighter connection failed'); + } + return; + } + + if (connector === 'walletconnect') { + // Signal to WalletModal that it should open the WalletConnectModal. + // The modal calls resolveWalletConnect() once the session is live. + set({ walletConnectPending: true }); + // connect() returns here; the actual address is set via resolveWalletConnect. + return; + } + + throw new Error('Unsupported connector'); + } catch (error) { + console.error('Failed to connect wallet', error); + captureException(error, { source: 'wallet' }); + + if (error instanceof FreighterNotInstalledError) { + set({ connectError: { type: 'not_installed', message: error.message } }); + } else if (error instanceof FreighterCancelledError) { + set({ connectError: { type: 'cancelled', message: error.message } }); + } else if (error instanceof FreighterNetworkMismatchError) { + set({ + connectError: { + type: 'network_mismatch', + message: error.message, + expectedNetwork: error.expectedNetwork, + freighterNetwork: error.freighterNetwork, + }, + }); + } else { + set({ + connectError: { + type: 'generic', + message: error instanceof Error ? error.message : 'An unexpected error occurred', + raw: String(error), + }, + }); + } + + throw error; + } + }, + + resolveWalletConnect: (session: WalletConnectSession) => { + const stellarAccounts = session.stellarAccounts && session.stellarAccounts.length > 0 + ? session.stellarAccounts + : session.address ? [session.address] : []; + const selectedAddress = session.address && stellarAccounts.includes(session.address) + ? session.address + : stellarAccounts[0] || null; + + set({ + address: selectedAddress, + stellarAccounts, + isConnected: true, + connector: 'walletconnect', + connectError: null, + walletConnectPending: false, + walletConnectSession: { + ...session, + address: selectedAddress || session.address, + stellarAccounts, + }, + }); + get().refreshBalances(); + }, + + cancelWalletConnect: () => { + set({ walletConnectPending: false }); + }, + + selectAccount: (address: string) => { + const { stellarAccounts, address: currentAddress } = get(); + if (!address || address === currentAddress) return; + if (stellarAccounts.length > 0 && !stellarAccounts.includes(address)) return; + + set({ address, balances: [], loading: true, error: null }); + get().refreshBalances(); + }, + + disconnect: () => { + // Clean up WalletConnect WebSocket if it was the active connector + if (get().connector === 'walletconnect') { + resetWalletConnectClient(); + } + set({ + address: null, + stellarAccounts: [], + isConnected: false, + connector: null, + balances: [], + loading: false, + isReconnecting: false, + error: null, + connectError: null, + walletConnectPending: false, + walletConnectSession: null, + }); + }, + + clearConnectError: () => { + set({ connectError: null }); + }, + + setWalletModalOpen: (open: boolean) => { + if (!open) { + set({ walletModalOpen: false, connectError: null, walletConnectPending: false }); + } else { + set({ walletModalOpen: true }); + } + }, + + setNetwork: (network: 'testnet' | 'public') => { + const current = get().network; + if (current === network) return; + set({ network, balances: [], loading: true, error: null }); + get().refreshBalances(); + }, + + signTransaction: async (xdr: string): Promise => { + const { connector } = get(); + + if (connector === 'freighter') { + const { signWithFreighter } = await import('@/lib/stellar/freighter'); + const signed = await signWithFreighter(xdr); + if (!signed) throw new Error('Freighter rejected the transaction'); + return signed; + } + + if (connector === 'walletconnect') { + const client = getWalletConnectClient(); + return client.signTransaction(xdr); + } + + throw new Error('No wallet connected'); + }, + + signMessage: async (message: string): Promise => { + const { connector, address } = get(); + + if (connector === 'freighter') { + const { signChallenge } = await import('@/lib/stellar/freighter'); + const sig = await signChallenge(address!, message); + if (!sig) throw new Error('Freighter rejected signing the message'); + return sig; + } + + if (connector === 'walletconnect') { + const client = getWalletConnectClient(); + return client.signMessage(message, address!); + } + + throw new Error('No wallet connected'); + }, + + refreshBalances: async () => { + const { address, network } = get(); + if (!address) return; + + set({ loading: true, error: null, isReconnecting: false }); + + const horizonUrl = NETWORK_URLS[network]; + + try { + const result = await retryWithBackoff( + async () => { + const response = await fetch(`${horizonUrl}/accounts/${address}`); + + if (!response.ok) { + if (response.status === 404) return 'NOT_FOUND' as const; + throw new Error(`Horizon error: ${response.status} ${response.statusText}`); + } + + return await response.json(); + }, + { + maxRetries: 3, + baseDelay: 500, + maxDelay: 3000, + isRetryable: () => true, + onRetry: (_err, attempt) => { + set({ isReconnecting: true }); + }, + }, + ); + + set({ isReconnecting: false }); + + if (result === 'NOT_FOUND') { + set({ balances: [], loading: false }); + return; + } + + const data = result as { + balances: Array<{ + asset_type: string; + balance: string; + asset_code?: string; + asset_issuer?: string; + }>; + }; + + const balances: AssetBalance[] = data.balances.map((b) => { + if (b.asset_type === 'native') return { assetCode: 'XLM', balance: b.balance }; + return { assetCode: b.asset_code!, balance: b.balance, assetIssuer: b.asset_issuer }; + }); + + set({ balances, loading: false, error: null }); + } catch (error) { + console.error('Failed to refresh balances', error); + captureException(error, { source: 'wallet' }); + set({ + loading: false, + isReconnecting: false, + error: error instanceof Error ? error.message : 'Failed to fetch balances', + }); + } + }, +})); + +// Let error reports carry wallet context. Registered here rather than imported +// by the reporting module so the Stellar SDK is only pulled into bundles that +// actually use the wallet. The address is deliberately never exposed. +setWalletContextProvider(() => { + const { isConnected, connector, network } = useWalletStore.getState(); + return { connected: isConnected, connector, network }; +});