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 (
-
-
+
+
-
+
-
-
+
+
-
- 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) => (
setCurrentStep(i)}
className={cn(
- "w-2 h-2 rounded-full transition-all duration-300",
+ "h-2 rounded-full transition-all duration-300",
i === currentStep
- ? "bg-primary w-6"
+ ? "w-6 bg-primary"
: i < currentStep
- ? "bg-primary/40"
- : "bg-muted-foreground/20 hover:bg-muted-foreground/40"
+ ? "w-2 bg-primary/40"
+ : "w-2 bg-muted-foreground/20 hover:bg-muted-foreground/40",
)}
- aria-label={`Go to step ${i + 1}`}
+ aria-label={"Go to step " + (i + 1)}
/>
))}
Skip all
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 */}
)}
- {/* Spinner overlay for approving / signing states */}
{showSpinner && (
@@ -216,7 +207,6 @@ export function WalletConnectModal({
)}
- {/* Connected confirmation */}
{status === 'connected' && (
@@ -226,7 +216,6 @@ export function WalletConnectModal({
)}
- {/* Error state */}
{status === 'error' && (
@@ -250,7 +239,6 @@ export function WalletConnectModal({
)}
- {/* Status label while waiting (connecting + URI already shown via QR) */}
{status === 'connecting' && uri && (
@@ -258,18 +246,6 @@ export function WalletConnectModal({
)}
-
- {/* Footer cancel */}
-
- handleOpenChange(false)}
- >
-
- Cancel
-
-
);
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.
+
-
{}}>
-
-
-
+
{}}>
+
+
+
-
-
- Cancel
-
+
+
+ Cancel
+
+
-
+
+
+ >
);
}
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}
)}
Retry Connection
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
) => (
+ {children}
+ ),
+}));
+
+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 };
+});