diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 27e3ebe1..2cef03c7 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -86,6 +86,11 @@ export default function SettingsPage() { Dashboard Widgets +
  • + + Wallet Verification + +
  • diff --git a/src/app/settings/security/page.tsx b/src/app/settings/security/page.tsx index 011d6b5b..456c9a96 100644 --- a/src/app/settings/security/page.tsx +++ b/src/app/settings/security/page.tsx @@ -4,6 +4,13 @@ import Image from 'next/image'; import Link from 'next/link'; import { useEffect, useState } from 'react'; import { apiFetch } from '@/lib/apiClient'; +import { + clearWalletConnection, + getWalletConnection, + getWalletPersistencePreference, + setWalletPersistencePreference, + type WalletConnection, +} from '@/lib/walletConnection'; interface SecuritySettingsResponse { mfaEnabled: boolean; @@ -24,10 +31,14 @@ export default function SecuritySettingsPage() { const [message, setMessage] = useState(null); const [loading, setLoading] = useState(false); const [userId, setUserId] = useState('default-user'); + const [wallet, setWallet] = useState(null); + const [persistWallet, setPersistWallet] = useState(true); useEffect(() => { const stored = localStorage.getItem('stellarsplit_wallet_address') || 'default-user'; setUserId(stored); + setWallet(getWalletConnection()); + setPersistWallet(getWalletPersistencePreference()); const load = async () => { try { @@ -43,6 +54,24 @@ export default function SecuritySettingsPage() { load(); }, []); + const handleDisconnectWallet = () => { + clearWalletConnection(); + setWallet(null); + setMessage('Wallet disconnected.'); + }; + + const handleTogglePersistence = (next: boolean) => { + setPersistWallet(next); + setWalletPersistencePreference(next); + if (!next) { + clearWalletConnection(); + setWallet(null); + setMessage('Wallet connection persistence disabled and stored connection cleared.'); + } else { + setMessage('Wallet connection will be remembered on this device.'); + } + }; + const refreshSettings = async (id: string) => { try { const response = await fetch(`/api/settings/mfa?userId=${encodeURIComponent(id)}`); @@ -157,6 +186,53 @@ export default function SecuritySettingsPage() { ← Back to settings +
    +
    +
    +

    Wallet connection

    +

    Your wallet connection is remembered on this device so you can pick up where you left off.

    +
    +
    + {wallet ? 'Connected' : 'Disconnected'} +
    +
    + +
    +
    +

    Current connection

    + {wallet ? ( + <> +

    Wallet: {wallet.walletType}

    +

    Address: {wallet.address}

    + + ) : ( +

    No wallet is currently connected.

    + )} + +
    + +
    +

    Privacy

    + +
    +
    +
    +
    diff --git a/src/app/settings/verify/page.tsx b/src/app/settings/verify/page.tsx index ed59bd17..3368238a 100644 --- a/src/app/settings/verify/page.tsx +++ b/src/app/settings/verify/page.tsx @@ -15,10 +15,22 @@ import { isValidUrl, type VerificationRequest, } from '@/lib/verificationRequests'; +import { useAddressValidation } from '@/hooks/useAddressValidation'; +import { WalletAddress } from '@/components/WalletAddress'; export default function VerifyIdentityPage() { const [mounted, setMounted] = useState(false); const [address, setAddress] = useState(null); + const [walletInput, setWalletInput] = useState(''); + const { + input: validatedInput, + setInput: setValidatedInput, + result: validationResult, + loading: validating, + error: validationError, + validate: validateAddress, + reset: resetValidation, + } = useAddressValidation(); const [attestation, setAttestation] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -188,7 +200,7 @@ export default function VerifyIdentityPage() {
    Connected Address: - {address} +
    ) : ( @@ -198,6 +210,105 @@ export default function VerifyIdentityPage() { )}
    + {/* Wallet Address Verification */} +
    +

    Verify a Wallet Address

    +

    + Enter a Stellar address or a federation address (e.g. name*domain.com) + to validate it and resolve it to its Stellar account. +

    + +
    + setValidatedInput(e.target.value)} + placeholder="G... or name*domain.com" + aria-label="Wallet address or federation address" + className="flex-1 px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-gray-200 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-sm font-mono" + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void validateAddress(); + } + }} + /> + + {(validationResult || validationError) && ( + + )} +
    + + {validationError && ( +
    + {validationError} +
    + )} + + {validationResult && ( +
    +
    + + ✓ Valid address + + {validationResult.federation && ( + + Resolved from federation + + )} +
    + + {validationResult.federation && ( +
    +
    + Federation address: + + {validationResult.federation} + +
    +
    + )} + +
    + Stellar address: + +
    + + {validationResult.memo && ( +
    + Memo: + + {validationResult.memo.type}: {validationResult.memo.value} + +
    + )} + + {address && validationResult.address !== address && ( + + )} +
    + )} +
    + {/* Verification Status */}

    Verification Status

    diff --git a/src/components/NetworkStatus.tsx b/src/components/NetworkStatus.tsx index 4d5d2aa1..c48ece85 100644 --- a/src/components/NetworkStatus.tsx +++ b/src/components/NetworkStatus.tsx @@ -1,6 +1,8 @@ "use client"; import { useEffect, useState, useRef, useCallback } from "react"; +import { useWalletBalance } from "../hooks/useWalletBalance"; +import { useWalletConnect } from "../hooks/useWalletConnect"; const RPC_URL = process.env.NEXT_PUBLIC_RPC_URL ?? ""; const POLL_MS = 60_000; @@ -67,6 +69,9 @@ export default function NetworkStatus() { const [browserOffline, setBrowserOffline] = useState(false); const timerRef = useRef | null>(null); + const { lowBalance, lowBalanceAssets } = useWalletBalance(); + const { status: wcStatus } = useWalletConnect(); + const poll = useCallback(async () => { const s = await checkHealth(); setStatus((prev) => { @@ -153,6 +158,36 @@ export default function NetworkStatus() { }` : "Soroban RPC: checking…"; + // ── WalletConnect connection indicator styles ─────────────────────────────── + + const wcConnected = wcStatus === "connected"; + const wcPending = wcStatus === "connecting" || wcStatus === "pairing"; + + const wcDotColor = wcConnected + ? "bg-blue-400" + : wcPending + ? "bg-blue-400 animate-pulse" + : wcStatus === "error" || wcStatus === "disconnected" + ? "bg-gray-500" + : "bg-gray-400"; + + const wcLabel = wcConnected + ? "Wallet connected" + : wcPending + ? "Connecting…" + : wcStatus === "error" + ? "Wallet error" + : "Wallet disconnected"; + + const wcTooltip = `WalletConnect: ${wcLabel}`; + + // ── Low balance warning styles ────────────────────────────────────────────── + + const lowBalanceTooltip = + lowBalanceAssets && lowBalanceAssets.length > 0 + ? `Low balance: ${lowBalanceAssets.join(", ")}` + : "One or more token balances are low"; + // ── Network badge styles ───────────────────────────────────────────────────── const networkBadgeClasses = @@ -209,8 +244,29 @@ export default function NetworkStatus() {
    )} - {/* ── Network badge + RPC health ── */} + {/* ── Network badge + RPC health + WalletConnect + low balance ── */}
    + {/* Low balance warning badge */} + {lowBalance && ( +
    + + + Low balance + + {/* Tooltip */} + + {lowBalanceTooltip} + +
    + )} + {/* Permanent Mainnet / Testnet badge */}
    + + {/* WalletConnect connection dot */} +
    +
    ); diff --git a/src/components/PaymentSourceBar.tsx b/src/components/PaymentSourceBar.tsx index 2baa5cfd..f1c63e47 100644 --- a/src/components/PaymentSourceBar.tsx +++ b/src/components/PaymentSourceBar.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { formatAmount, truncateAddress } from "@stellar-split/sdk"; import type { Payment } from "@stellar-split/sdk"; +import { formatAssetAmount } from "../lib/balanceFormat"; const COLORS = ["#6366f1", "#22d3ee", "#f59e0b", "#10b981", "#f43f5e", "#a78bfa", "#fb923c"]; @@ -55,7 +56,7 @@ export default function PaymentSourceBar({ payments, total }: Props) { className="relative group cursor-pointer transition-opacity hover:opacity-80" onMouseEnter={() => setHoveredPayer(segment.payer)} onMouseLeave={() => setHoveredPayer(null)} - title={`${segment.payer}: ${formatAmount(segment.amount)} USDC`} + title={`${segment.payer}: ${formatAssetAmount(segment.amount, "USDC")}`} > {segment.percentage > 8 && ( @@ -64,7 +65,7 @@ export default function PaymentSourceBar({ payments, total }: Props) { )} {hoveredPayer === segment.payer && (
    - {truncateAddress(segment.payer)}: {formatAmount(segment.amount)} USDC + {truncateAddress(segment.payer)}: {formatAssetAmount(segment.amount, "USDC")}
    )}
    @@ -73,7 +74,7 @@ export default function PaymentSourceBar({ payments, total }: Props) {
    )}
    @@ -97,7 +98,7 @@ export default function PaymentSourceBar({ payments, total }: Props) { - {formatAmount(segment.amount)} USDC + {formatAssetAmount(segment.amount, "USDC")} ))} @@ -108,7 +109,7 @@ export default function PaymentSourceBar({ payments, total }: Props) { Unfunded - {formatAmount(unfunded)} USDC + {formatAssetAmount(unfunded, "USDC")} )} diff --git a/src/components/QRModal.tsx b/src/components/QRModal.tsx index 0a1d698a..bbd82829 100644 --- a/src/components/QRModal.tsx +++ b/src/components/QRModal.tsx @@ -3,48 +3,70 @@ import { useEffect, useRef } from "react"; import FocusTrap from "./FocusTrap"; import { QRCodeCanvas } from "qrcode.react"; -import { isWalletConnected } from "@/lib/freighter"; + +export type WalletConnectStatus = + | "idle" + | "connecting" + | "awaiting_scan" + | "session_approved" + | "timeout" + | "error"; type QRModalProps = { open: boolean; uri: string; + status?: WalletConnectStatus; + errorMessage?: string; onClose: () => void; onCopied?: () => void; + onRetry?: () => void; onConnected?: () => void; - pollingInterval?: number; +}; + +const STATUS_TEXT: Record = { + idle: "Preparing connection…", + connecting: "Connecting to WalletConnect…", + awaiting_scan: "Waiting for wallet to scan and approve…", + session_approved: "Session approved. Closing…", + timeout: "Connection timed out. The QR code may have expired.", + error: "Something went wrong while connecting.", }; export default function QRModal({ open, uri, + status = "awaiting_scan", + errorMessage, onClose, onCopied, + onRetry, onConnected, - pollingInterval = 2000, }: QRModalProps) { const firedRef = useRef(false); + // Close the modal once the parent reports a successful session approval. useEffect(() => { if (!open) { firedRef.current = false; return; } - - const id = setInterval(async () => { - if (firedRef.current) return; - const connected = await isWalletConnected(); - if (connected) { - firedRef.current = true; - clearInterval(id); - onConnected?.(); - } - }, pollingInterval); - - return () => clearInterval(id); - }, [open, onConnected, pollingInterval]); + if (status === "session_approved" && !firedRef.current) { + firedRef.current = true; + onConnected?.(); + onClose(); + } + }, [open, status, onConnected, onClose]); if (!open) return null; + const isBusy = status === "idle" || status === "connecting"; + const isAwaiting = status === "awaiting_scan"; + const isTimeout = status === "timeout"; + const isError = status === "error"; + const canShowQR = !!uri && !isTimeout && !isError; + const statusText = isError && errorMessage ? errorMessage : STATUS_TEXT[status]; +====== + const handleCopy = async () => { try { await navigator.clipboard.writeText(uri); @@ -82,36 +104,74 @@ export default function QRModal({
    -
    - + {canShowQR ? ( +
    + + {(isBusy || isAwaiting) && ( +
    +
    + )} +
    + ) : ( +
    +

    {statusText}

    +
    + )} + +
    + {(isBusy || isAwaiting) && ( +
    -
    - + {canShowQR && ( +
    + -

    - {uri} -

    -
    +

    + {uri} +

    +
    + )}
    -
    +
    + {(isTimeout || isError) && onRetry && ( + + )}
    diff --git a/src/components/RecipientForm.tsx b/src/components/RecipientForm.tsx index 276c05be..b36928b5 100644 --- a/src/components/RecipientForm.tsx +++ b/src/components/RecipientForm.tsx @@ -7,6 +7,7 @@ import { searchRecipients, touchRecipient, type RecipientEntry } from "@/lib/rec import { truncateAddress } from "@stellar-split/sdk"; import CsvRecipientImport from "@/components/CsvRecipientImport"; import { useEmailValidation } from "@/hooks/useEmailValidation"; +import { useAddressValidation } from "@/hooks/useAddressValidation"; import LineItemRow from "@/components/LineItemRow"; export interface RecipientRow { @@ -113,6 +114,13 @@ export default function RecipientForm({ const savedRecipients = useMemo(() => searchRecipients(savedSearchQuery), [savedSearchQuery]); + // Validate every recipient address and resolve federation/ENS names. + const addressValidation = useAddressValidation(recipients.map((r) => r.address)); + const hasInvalidAddress = recipients.some((r, i) => { + const result = addressValidation[i]; + return !!r.address.trim() && (!result || !result.valid || result.resolving); + }); + // Contact emails come from localStorage, so they can only be resolved after // mount; the deterministic avatar renders identically on both passes. const [mounted, setMounted] = useState(false); @@ -189,9 +197,17 @@ export default function RecipientForm({ onAmountSuggestionSelect={(amount) => selectAmountSuggestion(i, amount)} onRemove={() => removeRow(i)} onEmailChange={(email) => updateRow(i, row.address, row.label, email)} + validation={addressValidation[i]} + onResolvedAddress={(address) => updateRow(i, address)} /> ))} + {hasInvalidAddress && ( +

    + One or more recipient addresses are invalid or still resolving. Please verify them before continuing. +

    + )} + + ) : status === "failed" ? ( + <> + + + + Could not restore wallet connection. + {onRetry && ( + + )} + {onManualConnect && ( + + )} + + ) : ( <> - Connection lost — reconnecting... + Restoring wallet connection...
    {error &&

    Could not connect wallet.

    } + {wcStatus !== "idle" && wcStatus !== "connected" && ( +
    + {(wcStatus === "awaiting_scan" || + wcStatus === "connecting" || + wcStatus === "retrying") && ( +
    + )} + setQrOpen(false)} - onConnected={() => setQrOpen(false)} + onClose={handleWcClose} + onConnected={() => { + setWcStatus("connected"); + setQrOpen(false); + }} /> + + setWcError(null)} + onRetry={() => { + setWcError(null); + handleWcRetry(); + }} + /> ); } diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index de9b7b84..b25e2afb 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -1,14 +1,42 @@ "use client"; -import { createContext, useContext, useEffect, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { useWallet, type UseWalletReturn } from "@/hooks/useWallet"; import { useSep10Auth, type Sep10AuthState } from "@/hooks/useSep10Auth"; +import { useWalletConnect, type UseWalletConnectReturn } from "@/hooks/useWalletConnect"; +import { + useWalletPersistence, + type UseWalletPersistenceReturn, +} from "@/hooks/useWalletPersistence"; + +export type ReconnectionStatus = + | "idle" + | "restoring" + | "reconnecting" + | "connected" + | "failed"; export type WalletContextValue = UseWalletReturn & { sep10: Sep10AuthState & { authenticate: (publicKey: string) => Promise; handleUnauthorized: () => void; }; + walletConnect: UseWalletConnectReturn; + persistence: UseWalletPersistenceReturn; + /** Status of the auto-reconnection attempt on page load. */ + reconnectionStatus: ReconnectionStatus; + /** True once a persisted connection has been resolved (success or failure). */ + reconnectionSettled: boolean; + /** Manually disconnect the wallet and clear persisted connection data. */ + disconnect: () => Promise; }; const WalletContext = createContext(null); @@ -16,15 +44,114 @@ const WalletContext = createContext(null); export function WalletProvider({ children }: { children: ReactNode }) { const wallet = useWallet(); const sep10 = useSep10Auth(); + const walletConnect = useWalletConnect(); + const persistence = useWalletPersistence(); + + const [reconnectionStatus, setReconnectionStatus] = + useState("idle"); + const [reconnectionSettled, setReconnectionSettled] = useState(false); + const reconnectAttempted = useRef(false); - // Auto-authenticate when a wallet address becomes available + // Auto-reconnect from persisted state on mount (#888). useEffect(() => { + if (reconnectAttempted.current) return; + reconnectAttempted.current = true; + + let cancelled = false; + + const restore = async () => { + const saved = persistence.load(); + if (!saved) { + if (!cancelled) { + setReconnectionStatus("idle"); + setReconnectionSettled(true); + } + return; + } + + setReconnectionStatus("restoring"); + + try { + setReconnectionStatus("reconnecting"); + const restored = await persistence.reconnect(saved); + if (cancelled) return; + + if (restored) { + setReconnectionStatus("connected"); + } else { + setReconnectionStatus("failed"); + persistence.clear(); + } + } catch { + if (cancelled) return; + setReconnectionStatus("failed"); + persistence.clear(); + } finally { + if (!cancelled) setReconnectionSettled(true); + } + }; + + void restore(); + + return () => { + cancelled = true; + }; + }, [persistence]); + + // Persist connection data whenever a verified address becomes available (#888). + useEffect(() => { + if (wallet.address && wallet.isConnected) { + persistence.save({ + address: wallet.address, + walletId: wallet.walletId, + connector: walletConnect.isConnected ? "walletconnect" : "injected", + }); + } + }, [ + wallet.address, + wallet.isConnected, + wallet.walletId, + walletConnect.isConnected, + persistence, + ]); + + // Auto-authenticate only after a verified address is available. When a + // persisted connection is being restored, wait until it settles so SEP-10 + // runs against the restored (verified) address (#884, #888). + useEffect(() => { + if (!reconnectionSettled) return; if (wallet.address && !sep10.isAuthenticated && !sep10.isAuthenticating) { sep10.authenticate(wallet.address); } - }, [wallet.address, sep10.isAuthenticated, sep10.isAuthenticating, sep10.authenticate]); + }, [ + reconnectionSettled, + wallet.address, + sep10.isAuthenticated, + sep10.isAuthenticating, + sep10.authenticate, + ]); + + const disconnect = useCallback(async () => { + try { + if (walletConnect.isConnected) { + await walletConnect.disconnect(); + } + await wallet.disconnect(); + } finally { + persistence.clear(); + setReconnectionStatus("idle"); + } + }, [walletConnect, wallet, persistence]); - const value: WalletContextValue = { ...wallet, sep10 }; + const value: WalletContextValue = { + ...wallet, + sep10, + walletConnect, + persistence, + reconnectionStatus, + reconnectionSettled, + disconnect, + }; return {children}; } @@ -37,3 +164,6 @@ export function useWalletContext(): WalletContextValue { } return ctx; } + + + \ No newline at end of file diff --git a/src/hooks/useAddressLabel.ts b/src/hooks/useAddressLabel.ts index bbb7e23a..b09f2801 100644 --- a/src/hooks/useAddressLabel.ts +++ b/src/hooks/useAddressLabel.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { searchAddressHistory } from "@/lib/invoiceHistory"; import { getAddressBook } from "@/lib/addressBook"; +import { resolveFederationAddress } from "@/lib/stellar"; /** * Derives a smart label suggestion for a Stellar address or Federation address: @@ -13,10 +14,11 @@ export async function resolveAddressLabel(address: string): Promise 0) { diff --git a/src/hooks/useAddressValidation.ts b/src/hooks/useAddressValidation.ts index 0b52ec1a..74aa7a9e 100644 --- a/src/hooks/useAddressValidation.ts +++ b/src/hooks/useAddressValidation.ts @@ -25,6 +25,67 @@ export interface UseAddressValidationOptions { } const DEBOUNCE_MS = 300; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const CACHE_STORAGE_KEY = 'stellar:address-resolution-cache:v1'; + +interface CacheEntry { + resolvedAddress: string; + memoId?: string; + memoType?: string; + timestamp: number; +} + +const memoryCache = new Map(); + +function readCacheFromStorage(): void { + if (typeof window === 'undefined' || !window.localStorage) return; + try { + const raw = window.localStorage.getItem(CACHE_STORAGE_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + const now = Date.now(); + Object.entries(parsed).forEach(([key, entry]) => { + if (entry && typeof entry.timestamp === 'number' && now - entry.timestamp < CACHE_TTL_MS) { + memoryCache.set(key, entry); + } + }); + } catch { + // Corrupt or unavailable storage: ignore and continue with empty cache + } +} + +function persistCacheToStorage(): void { + if (typeof window === 'undefined' || !window.localStorage) return; + try { + const now = Date.now(); + const serializable: Record = {}; + memoryCache.forEach((entry, key) => { + if (now - entry.timestamp < CACHE_TTL_MS) { + serializable[key] = entry; + } + }); + window.localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(serializable)); + } catch { + // Storage may be full or disabled; caching is best-effort only + } +} + +function getCachedResolution(address: string): CacheEntry | null { + const entry = memoryCache.get(address); + if (!entry) return null; + if (Date.now() - entry.timestamp >= CACHE_TTL_MS) { + memoryCache.delete(address); + return null; + } + return entry; +} + +function setCachedResolution(address: string, entry: Omit): void { + memoryCache.set(address, { ...entry, timestamp: Date.now() }); + persistCacheToStorage(); +} + +readCacheFromStorage(); /** * Hook for validating Stellar addresses with federation lookup and on-network verification @@ -92,40 +153,54 @@ export function useAddressValidation(options: UseAddressValidationOptions = {}) return; } - try { - const resolved = await resolveFederationAddress(address); - if (!resolved) { + const cached = getCachedResolution(address); + if (cached) { + resolvedAddr = cached.resolvedAddress; + federationMemoId = cached.memoId; + federationMemoType = cached.memoType; + } else { + try { + const resolved = await resolveFederationAddress(address); + if (!resolved) { + setValidationState({ + isValid: false, + isLoading: false, + error: `Federation address could not be resolved. Please verify the address is correct.`, + errorReason: null, + }); + return; + } + resolvedAddr = resolved; + + // Try to extract memo info from federation resolution + try { + const { Federation } = await import('@stellar/stellar-sdk'); + const federationResult = await Federation.Server.resolve(address); + if (federationResult.memo) { + federationMemoId = federationResult.memo; + } + if (federationResult.memo_type) { + federationMemoType = federationResult.memo_type; + } + } catch { + // Memo extraction failed, continue with address validation + } + + setCachedResolution(address, { + resolvedAddress: resolvedAddr, + memoId: federationMemoId, + memoType: federationMemoType, + }); + } catch { setValidationState({ isValid: false, isLoading: false, - error: `Federation address could not be resolved. Please verify the address is correct.`, + error: + 'We could not reach the federation server for this address. Please check the address or try again later.', errorReason: null, }); return; } - resolvedAddr = resolved; - - // Try to extract memo info from federation resolution - try { - const { Federation } = await import('@stellar/stellar-sdk'); - const federationResult = await Federation.Server.resolve(address); - if (federationResult.memo) { - federationMemoId = federationResult.memo; - } - if (federationResult.memo_type) { - federationMemoType = federationResult.memo_type; - } - } catch { - // Memo extraction failed, continue with address validation - } - } catch (error) { - setValidationState({ - isValid: false, - isLoading: false, - error: 'Failed to resolve federation address', - errorReason: null, - }); - return; } } @@ -149,7 +224,15 @@ export function useAddressValidation(options: UseAddressValidationOptions = {}) } // Check if account exists and is funded - const { isFunded, requiresMemo } = await validateFederationAddress(resolvedAddr); + let isFunded = false; + let requiresMemo = false; + try { + const verification = await validateFederationAddress(resolvedAddr); + isFunded = verification.isFunded; + requiresMemo = verification.requiresMemo; + } catch { + // Network verification failed; the address format is still valid + } setValidationState({ isValid: true, @@ -167,7 +250,10 @@ export function useAddressValidation(options: UseAddressValidationOptions = {}) setValidationState({ isValid: false, isLoading: false, - error: error instanceof Error ? error.message : 'Error validating address', + error: + error instanceof Error && error.message + ? error.message + : 'Something went wrong while validating this address. Please try again.', errorReason: null, }); } diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 87d09486..d45fdd06 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -3,18 +3,21 @@ import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { connectFreighter, - connectWalletConnect, disconnectWalletConnect, getFreighterPublicKey, getWalletConnectPublicKey, } from "@/lib/freighter"; import type { WalletType } from "@/lib/freighter"; +import { useWalletConnect } from "@/hooks/useWalletConnect"; +import type { WalletConnectStatus } from "@/hooks/useWalletConnect"; const STORAGE_KEY = "stellarsplit_wallet"; +const PRIVACY_KEY = "stellarsplit_wallet_persistence"; interface CachedWallet { address: string; walletType: WalletType; + timestamp: number; } interface WalletState { @@ -25,13 +28,31 @@ interface WalletState { freighterInstalled: boolean | null; } +/** + * Whether the user has opted in to persisting their wallet connection across + * sessions. Defaults to enabled unless explicitly disabled. + */ +function isPersistenceEnabled(): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(PRIVACY_KEY) !== "disabled"; + } catch { + return false; + } +} + function readCache(): CachedWallet | null { if (typeof window === "undefined") return null; + if (!isPersistenceEnabled()) return null; try { - const raw = window.sessionStorage.getItem(STORAGE_KEY); + const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); - if (typeof parsed?.address === "string" && typeof parsed?.walletType === "string") { + if ( + typeof parsed?.address === "string" && + typeof parsed?.walletType === "string" && + typeof parsed?.timestamp === "number" + ) { return parsed as CachedWallet; } return null; @@ -44,16 +65,34 @@ function writeCache(wallet: CachedWallet | null) { if (typeof window === "undefined") return; try { if (!wallet) { - window.sessionStorage.removeItem(STORAGE_KEY); - } else { - window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(wallet)); + window.localStorage.removeItem(STORAGE_KEY); + } else if (isPersistenceEnabled()) { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(wallet)); } } catch { - // sessionStorage unavailable (private mode, etc.) — connection state + // localStorage unavailable (private mode, etc.) — connection state // just won't persist across a refresh. } } +/** + * Enable or disable persistence of the wallet connection across sessions. + * Disabling also clears any stored connection data. + */ +export function setWalletPersistence(enabled: boolean) { + if (typeof window === "undefined") return; + try { + if (enabled) { + window.localStorage.removeItem(PRIVACY_KEY); + } else { + window.localStorage.setItem(PRIVACY_KEY, "disabled"); + window.localStorage.removeItem(STORAGE_KEY); + } + } catch { + // ignore storage failures + } +} + /** * Shared Freighter/WalletConnect connection state. * @@ -75,6 +114,7 @@ export function useWallet() { }; }); + const walletConnect = useWalletConnect(); const verified = useRef(false); useLayoutEffect(() => { @@ -107,7 +147,12 @@ export function useWallet() { // to the existing silent getWalletConnectPublicKey() check. if (cached.walletType === "freighter" && !freighterConnected) { writeCache(null); - setState((prev) => ({ ...prev, address: null, walletType: null })); + setState((prev) => ({ + ...prev, + address: null, + walletType: null, + error: "Saved wallet connection could not be restored. Please reconnect.", + })); return; } @@ -123,12 +168,22 @@ export function useWallet() { setState((prev) => ({ ...prev, address: key, walletType: cached.walletType })); } else { writeCache(null); - setState((prev) => ({ ...prev, address: null, walletType: null })); + setState((prev) => ({ + ...prev, + address: null, + walletType: null, + error: "Saved wallet connection could not be restored. Please reconnect.", + })); } } catch { if (!cancelled) { writeCache(null); - setState((prev) => ({ ...prev, address: null, walletType: null })); + setState((prev) => ({ + ...prev, + address: null, + walletType: null, + error: "Saved wallet connection could not be restored. Please reconnect.", + })); } } }; @@ -140,33 +195,48 @@ export function useWallet() { }; }, []); - const connect = useCallback(async (type: WalletType) => { - setState((prev) => ({ ...prev, connecting: true, error: null })); - try { - if (type === "freighter") { - const address = await connectFreighter(); - writeCache({ address, walletType: "freighter" }); - setState((prev) => ({ ...prev, address, walletType: "freighter", connecting: false })); - return { address, walletType: "freighter" as WalletType }; - } + const connect = useCallback( + async (type: WalletType) => { + setState((prev) => ({ ...prev, connecting: true, error: null })); + try { + if (type === "freighter") { + const address = await connectFreighter(); + writeCache({ address, walletType: "freighter", timestamp: Date.now() }); + setState((prev) => ({ ...prev, address, walletType: "freighter", connecting: false })); + return { address, walletType: "freighter" as WalletType }; + } - const { publicKey, uri } = await connectWalletConnect(); - writeCache({ address: publicKey, walletType: "walletconnect" }); - setState((prev) => ({ ...prev, address: publicKey, walletType: "walletconnect", connecting: false })); - return { address: publicKey, walletType: "walletconnect" as WalletType, uri }; - } catch (e) { - setState((prev) => ({ ...prev, connecting: false, error: String(e) })); - throw e; - } - }, []); + const { publicKey, uri } = await walletConnect.connect(); + writeCache({ address: publicKey, walletType: "walletconnect", timestamp: Date.now() }); + setState((prev) => ({ + ...prev, + address: publicKey, + walletType: "walletconnect", + connecting: false, + })); + return { address: publicKey, walletType: "walletconnect" as WalletType, uri }; + } catch (e) { + setState((prev) => ({ ...prev, connecting: false, error: String(e) })); + throw e; + } + }, + [walletConnect], + ); const disconnect = useCallback(async () => { if (state.walletType === "walletconnect") { + try { + await walletConnect.disconnect(); + } catch { + // fall through to the low-level disconnect as a best effort + } await disconnectWalletConnect(); } writeCache(null); setState((prev) => ({ ...prev, address: null, walletType: null, error: null })); - }, [state.walletType]); + }, [state.walletType, walletConnect]); + + const wcStatus: WalletConnectStatus = walletConnect.status; return { address: state.address, @@ -176,6 +246,12 @@ export function useWallet() { freighterInstalled: state.freighterInstalled, connect, disconnect, + // WalletConnect v2 pairing/connection state (#884) + wcStatus, + wcUri: walletConnect.uri, + wcRetrying: walletConnect.retrying, + wcRetry: walletConnect.retry, + wcCancel: walletConnect.cancel, }; } diff --git a/src/hooks/useWalletBalance.ts b/src/hooks/useWalletBalance.ts index 5cda29cc..4c5789bc 100644 --- a/src/hooks/useWalletBalance.ts +++ b/src/hooks/useWalletBalance.ts @@ -1,82 +1,246 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import useSWR from "swr"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { fetchAccountBalances, type AccountBalance } from "@/lib/stellar/balances"; +import { useInterval } from "@/hooks/useInterval"; -interface WalletBalance { - xlm: string; - usdc: string; +export interface AssetBalance { + asset: string; + balance: string; + formatted: string; + isLow: boolean; +} + +export interface BalanceHistoryEntry { + timestamp: number; + balances: Record; +} + +export interface LowBalanceWarning { + asset: string; + balance: string; + threshold: string; +} + +export interface UseWalletBalanceOptions { + enabled?: boolean; + /** Polling interval in milliseconds. Defaults to 30s. */ + pollIntervalMs?: number; + /** Per-asset low balance thresholds, keyed by asset code (e.g. "XLM", "USDC"). */ + thresholds?: Record; + /** Maximum number of history entries to retain. Defaults to 50. */ + maxHistory?: number; } const MAX_RETRIES = 3; const RETRY_DELAYS_MS = [1000, 2000, 4000]; +const DEFAULT_POLL_INTERVAL_MS = 30000; +const DEFAULT_MAX_HISTORY = 50; +const HISTORY_STORAGE_PREFIX = "wallet-balance-history:"; -const fetcher = (url: string) => fetch(url).then(r => { - if (!r.ok) { - throw new Error(`Wallet balance request failed with status ${r.status}`); +function formatAmount(value: string, decimals: number = 7): string { + const num = Number(value); + if (!Number.isFinite(num)) return value; + return num.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: decimals, + }); +} + +function isBelowThreshold(balance: string, threshold: string): boolean { + const b = Number(balance); + const t = Number(threshold); + if (!Number.isFinite(b) || !Number.isFinite(t)) return false; + return b < t; +} + +function loadHistory(address: string): BalanceHistoryEntry[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(HISTORY_STORAGE_PREFIX + address); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; } - return r.json(); -}); +} -export function useWalletBalance(address: string | null, enabled: boolean = true) { +function saveHistory(address: string, history: BalanceHistoryEntry[]) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + HISTORY_STORAGE_PREFIX + address, + JSON.stringify(history) + ); + } catch { + // Ignore storage failures (quota, private mode, etc.) + } +} + +export function useWalletBalance( + address: string | null, + options: UseWalletBalanceOptions = {} +) { + const { + enabled = true, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + thresholds = {}, + maxHistory = DEFAULT_MAX_HISTORY, + } = options; + + const [balances, setBalances] = useState(null); + const [history, setHistory] = useState([]); + const [isLoading, setIsLoading] = useState(false); const [isRetrying, setIsRetrying] = useState(false); + const [error, setError] = useState(undefined); + const retryCountRef = useRef(0); const retryTimeoutRef = useRef | null>(null); + const mountedRef = useRef(true); + const addressRef = useRef(address); - const { data, error, isLoading, mutate } = useSWR( - enabled && address ? `/api/wallet/balance?address=${address}` : null, - fetcher, - { - revalidateOnFocus: false, - dedupingInterval: 30000, - focusThrottleInterval: 30000, - shouldRetryOnError: false, - } - ); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + } + }; + }, []); + // Load persisted history when the address changes. useEffect(() => { - if (!error) { - retryCountRef.current = 0; - setIsRetrying(false); + addressRef.current = address; + if (!address) { + setHistory([]); return; } + setHistory(loadHistory(address)); + }, [address]); + + const fetchBalances = useCallback(async () => { + const currentAddress = addressRef.current; + if (!currentAddress) return; - if (retryCountRef.current >= MAX_RETRIES) { + setIsLoading(true); + try { + const result = await fetchAccountBalances(currentAddress); + if (!mountedRef.current) return; + + retryCountRef.current = 0; setIsRetrying(false); - return; - } + setError(undefined); + setBalances(result); - const delay = RETRY_DELAYS_MS[retryCountRef.current]; - retryCountRef.current += 1; - setIsRetrying(true); + const snapshot: BalanceHistoryEntry = { + timestamp: Date.now(), + balances: result.reduce>((acc, b) => { + acc[b.asset] = b.balance; + return acc; + }, {}), + }; - retryTimeoutRef.current = setTimeout(() => { - mutate(); - }, delay); + setHistory(prev => { + const next = [...prev, snapshot].slice(-maxHistory); + saveHistory(currentAddress, next); + return next; + }); + } catch (err) { + if (!mountedRef.current) return; + const normalized = + err instanceof Error ? err : new Error("Failed to fetch wallet balances"); + + if (retryCountRef.current >= MAX_RETRIES) { + setIsRetrying(false); + setError(normalized); + return; + } + + const delay = RETRY_DELAYS_MS[retryCountRef.current]; + retryCountRef.current += 1; + setIsRetrying(true); - return () => { if (retryTimeoutRef.current) { clearTimeout(retryTimeoutRef.current); } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [error]); + retryTimeoutRef.current = setTimeout(() => { + void fetchBalances(); + }, delay); + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + } + }, [maxHistory]); + // Initial fetch and refetch when the address changes. useEffect(() => { - if (data) { - retryCountRef.current = 0; + if (!enabled || !address) { + setBalances(null); + setError(undefined); setIsRetrying(false); + retryCountRef.current = 0; + return; } - }, [data]); + + retryCountRef.current = 0; + void fetchBalances(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address, enabled]); + + // Poll for updates. + useInterval( + () => { + void fetchBalances(); + }, + enabled && address ? pollIntervalMs : null + ); + + const assets: AssetBalance[] = useMemo(() => { + if (!balances) return []; + return balances.map(b => { + const threshold = thresholds[b.asset]; + return { + asset: b.asset, + balance: b.balance, + formatted: formatAmount(b.balance), + isLow: threshold !== undefined && isBelowThreshold(b.balance, threshold), + }; + }); + }, [balances, thresholds]); + + const warnings: LowBalanceWarning[] = useMemo( + () => + assets + .filter(a => a.isLow) + .map(a => ({ + asset: a.asset, + balance: a.balance, + threshold: thresholds[a.asset], + })), + [assets, thresholds] + ); + + const findBalance = (asset: string): string | null => + assets.find(a => a.asset === asset)?.balance ?? null; const retriesExhausted = retryCountRef.current >= MAX_RETRIES; return { - xlmBalance: data?.xlm ?? null, - usdcBalance: data?.usdc ?? null, + // Legacy fields preserved for existing consumers. + xlmBalance: findBalance("XLM"), + usdcBalance: findBalance("USDC"), + // New fields. + assets, + balances, + history, + warnings, + hasLowBalance: warnings.length > 0, isLoading, isRetrying, error: error && retriesExhausted ? error : undefined, - refetch: mutate, + refetch: fetchBalances, }; } diff --git a/src/hooks/useWalletConnect.ts b/src/hooks/useWalletConnect.ts new file mode 100644 index 00000000..9ef58b31 --- /dev/null +++ b/src/hooks/useWalletConnect.ts @@ -0,0 +1,416 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useToast } from './useToast'; + +export type WalletConnectStatus = + | 'idle' + | 'connecting' + | 'awaiting_scan' + | 'connected' + | 'error'; + +export interface WalletConnectSession { + topic: string; + chainId: number; + accounts: string[]; + expiry: number; +} + +export interface SignTransactionParams { + to: string; + value?: string; + data?: string; + gasLimit?: string; + gasPrice?: string; + nonce?: number; + chainId?: number; +} + +export interface SignTransactionResult { + hash: string; + rawTransaction?: string; +} + +export interface UseWalletConnectOptions { + projectId?: string; + relayUrl?: string; + chainId?: number; + metadata?: { + name: string; + description: string; + url: string; + icons: string[]; + }; + maxRetries?: number; + baseRetryDelayMs?: number; + sessionTimeoutMs?: number; + signTimeoutMs?: number; +} + +interface WalletConnectClientLike { + connect: (options: { + requiredNamespaces: Record; + }) => Promise<{ + uri?: string; + approval: () => Promise; + }>; + disconnect: (options: { + topic: string; + reason: { code: number; message: string }; + }) => Promise; + request: (options: { + topic: string; + chainId: string; + request: { method: string; params: unknown }; + }) => Promise; +} + +const DEFAULT_CHAIN_ID = 1; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_BASE_RETRY_DELAY_MS = 1000; +const DEFAULT_SESSION_TIMEOUT_MS = 120000; +const DEFAULT_SIGN_TIMEOUT_MS = 60000; + +const delay = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const withTimeout = ( + promise: Promise, + timeoutMs: number, + message: string, +): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(message)); + }, timeoutMs); + + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + return 'Unknown WalletConnect error'; +}; + +const buildRequiredNamespaces = (chainId: number): Record => ({ + eip155: { + methods: ['eth_sendTransaction', 'eth_signTransaction', 'personal_sign'], + chains: [`eip155:${chainId}`], + events: ['accountsChanged', 'chainChanged'], + }, +}); + +export const useWalletConnect = (options: UseWalletConnectOptions = {}) => { + const { + projectId, + relayUrl, + chainId = DEFAULT_CHAIN_ID, + metadata, + maxRetries = DEFAULT_MAX_RETRIES, + baseRetryDelayMs = DEFAULT_BASE_RETRY_DELAY_MS, + sessionTimeoutMs = DEFAULT_SESSION_TIMEOUT_MS, + signTimeoutMs = DEFAULT_SIGN_TIMEOUT_MS, + } = options; + + const { showToast } = useToast(); + + const [status, setStatus] = useState('idle'); + const [uri, setUri] = useState(null); + const [session, setSession] = useState(null); + const [error, setError] = useState(null); + const [attempt, setAttempt] = useState(0); + + const clientRef = useRef(null); + const mountedRef = useRef(true); + const cancelledRef = useRef(false); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + cancelledRef.current = true; + }; + }, []); + + const safeSetStatus = useCallback((next: WalletConnectStatus) => { + if (mountedRef.current) { + setStatus(next); + } + }, []); + + const safeSetError = useCallback((next: string | null) => { + if (mountedRef.current) { + setError(next); + } + }, []); + + const safeSetUri = useCallback((next: string | null) => { + if (mountedRef.current) { + setUri(next); + } + }, []); + + const safeSetSession = useCallback((next: WalletConnectSession | null) => { + if (mountedRef.current) { + setSession(next); + } + }, []); + + const safeSetAttempt = useCallback((next: number) => { + if (mountedRef.current) { + setAttempt(next); + } + }, []); + + const getClient = useCallback(async (): Promise => { + if (clientRef.current) { + return clientRef.current; + } + + if (!projectId) { + throw new Error('WalletConnect projectId is required'); + } + + // Lazy-load the WalletConnect v2 SDK to keep the initial bundle small. + const moduleName = '@walletconnect/sign-client'; + const sdk = (await import(/* webpackChunkName: "walletconnect" */ moduleName)) as { + SignClient: { + init: (config: Record) => Promise; + }; + }; + + const client = await sdk.SignClient.init({ + projectId, + relayUrl, + metadata, + }); + + clientRef.current = client; + return client; + }, [projectId, relayUrl, metadata]); + + const cleanupSession = useCallback(async () => { + const client = clientRef.current; + const currentSession = session; + if (client && currentSession) { + try { + await client.disconnect({ + topic: currentSession.topic, + reason: { code: 6000, message: 'User disconnected' }, + }); + } catch { + // Ignore disconnect errors during cleanup. + } + } + safeSetSession(null); + safeSetUri(null); + }, [session, safeSetSession, safeSetUri]); + + const connect = useCallback(async (): Promise => { + cancelledRef.current = false; + safeSetError(null); + safeSetUri(null); + safeSetSession(null); + safeSetStatus('connecting'); + safeSetAttempt(0); + + let lastError: unknown = null; + + for (let currentAttempt = 1; currentAttempt <= maxRetries; currentAttempt += 1) { + if (cancelledRef.current) { + return; + } + + safeSetAttempt(currentAttempt); + + try { + const client = await getClient(); + + const { uri: pairingUri, approval } = await client.connect({ + requiredNamespaces: buildRequiredNamespaces(chainId), + }); + + if (cancelledRef.current) { + return; + } + + if (pairingUri) { + safeSetUri(pairingUri); + } + safeSetStatus('awaiting_scan'); + showToast({ + type: 'info', + message: 'Scan the QR code with your mobile wallet to connect.', + }); + + const approvedSession = await withTimeout( + approval(), + sessionTimeoutMs, + 'WalletConnect session approval timed out', + ); + + if (cancelledRef.current) { + return; + } + + safeSetSession(approvedSession); + safeSetStatus('connected'); + safeSetError(null); + showToast({ + type: 'success', + message: 'Wallet connected successfully.', + }); + return; + } catch (err) { + lastError = err; + const message = getErrorMessage(err); + + if (cancelledRef.current) { + return; + } + + if (currentAttempt < maxRetries) { + const backoff = baseRetryDelayMs * 2 ** (currentAttempt - 1); + showToast({ + type: 'warning', + message: `Connection attempt ${currentAttempt} failed. Retrying in ${Math.round( + backoff / 1000, + )}s...`, + }); + await delay(backoff); + } else { + safeSetError(message); + safeSetStatus('error'); + showToast({ + type: 'error', + message: `WalletConnect failed after ${maxRetries} attempts: ${message}`, + }); + } + } + } + + if (lastError && !cancelledRef.current) { + safeSetError(getErrorMessage(lastError)); + safeSetStatus('error'); + } + }, [ + baseRetryDelayMs, + chainId, + getClient, + maxRetries, + safeSetAttempt, + safeSetError, + safeSetSession, + safeSetStatus, + safeSetUri, + sessionTimeoutMs, + showToast, + ]); + + const disconnect = useCallback(async (): Promise => { + cancelledRef.current = true; + await cleanupSession(); + safeSetStatus('idle'); + safeSetError(null); + showToast({ + type: 'info', + message: 'Wallet disconnected.', + }); + }, [cleanupSession, safeSetError, safeSetStatus, showToast]); + + const signTransaction = useCallback( + async (params: SignTransactionParams): Promise => { + if (!session) { + const message = 'No active WalletConnect session'; + safeSetError(message); + showToast({ type: 'error', message }); + throw new Error(message); + } + + const client = await getClient(); + const txChainId = params.chainId ?? session.chainId ?? chainId; + + const transaction = { + from: session.accounts[0], + to: params.to, + value: params.value, + data: params.data, + gasLimit: params.gasLimit, + gasPrice: params.gasPrice, + nonce: params.nonce, + chainId: txChainId, + }; + + try { + const result = await withTimeout( + client.request({ + topic: session.topic, + chainId: `eip155:${txChainId}`, + request: { + method: 'eth_sendTransaction', + params: [transaction], + }, + }), + signTimeoutMs, + 'Wallet signing request timed out', + ); + + const hash = typeof result === 'string' ? result : String(result); + showToast({ + type: 'success', + message: 'Transaction signed and submitted.', + }); + return { hash }; + } catch (err) { + const message = getErrorMessage(err); + safeSetError(message); + showToast({ + type: 'error', + message: `Transaction signing failed: ${message}`, + }); + throw err instanceof Error ? err : new Error(message); + } + }, + [chainId, getClient, safeSetError, session, showToast, signTimeoutMs], + ); + + const reset = useCallback(() => { + cancelledRef.current = true; + safeSetStatus('idle'); + safeSetError(null); + safeSetUri(null); + safeSetSession(null); + safeSetAttempt(0); + }, [safeSetAttempt, safeSetError, safeSetSession, safeSetStatus, safeSetUri]); + + return { + status, + uri, + session, + error, + attempt, + isConnected: status === 'connected', + isConnecting: status === 'connecting' || status === 'awaiting_scan', + connect, + disconnect, + signTransaction, + reset, + }; +}; + +export default useWalletConnect; \ No newline at end of file diff --git a/src/lib/config.ts b/src/lib/config.ts index 84a271b0..47a8b7dc 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -15,10 +15,72 @@ export const RPC_URL = /** StellarSplit contract ID used for on-chain calls and event filtering. */ export const contractId = process.env.NEXT_PUBLIC_CONTRACT_ID ?? ""; +/** + * WalletConnect v2 configuration (#884). + * + * The project ID is required for the WalletConnect Cloud relay; when it is + * missing the WalletConnect service should surface a clear error rather than + * attempting to connect. + */ +export const WALLETCONNECT_PROJECT_ID = + process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID ?? ""; + +export const WALLETCONNECT_RELAY_URL = + process.env.NEXT_PUBLIC_WALLETCONNECT_RELAY_URL ?? "wss://relay.walletconnect.com"; + +/** CAIP-2 chain identifiers for the Stellar networks we support. */ +export const STELLAR_CHAIN_IDS = { + mainnet: "stellar:pubnet", + testnet: "stellar:testnet", +} as const; + +export type StellarChainId = (typeof STELLAR_CHAIN_IDS)[keyof typeof STELLAR_CHAIN_IDS]; + +/** Chain IDs advertised to mobile wallets during session proposal. */ +export const SUPPORTED_CHAIN_IDS: readonly StellarChainId[] = [ + STELLAR_CHAIN_IDS.mainnet, + STELLAR_CHAIN_IDS.testnet, +]; + +/** The chain ID matching the currently configured network. */ +export const ACTIVE_CHAIN_ID: StellarChainId = STELLAR_CHAIN_IDS[STELLAR_NETWORK]; + +/** Session lifetime before the pairing is considered stale (ms). */ +export const WALLETCONNECT_SESSION_TIMEOUT_MS = 5 * 60 * 1000; + +/** Timeout for a single signing request round-trip (ms). */ +export const WALLETCONNECT_SIGN_TIMEOUT_MS = 60 * 1000; + +/** Retry policy for transient relay / signing failures. */ +export const WALLETCONNECT_RETRY_POLICY = { + maxAttempts: 3, + initialDelayMs: 1000, + maxDelayMs: 8000, + backoffFactor: 2, +} as const; + +export const walletConnectConfig = { + projectId: WALLETCONNECT_PROJECT_ID, + relayUrl: WALLETCONNECT_RELAY_URL, + metadata: { + name: "StellarSplit", + description: "Split and settle payments on Stellar", + url: process.env.NEXT_PUBLIC_APP_URL ?? "https://stellarsplit.app", + icons: ["https://stellarsplit.app/icon.png"], + }, + network: STELLAR_NETWORK, + chainIds: SUPPORTED_CHAIN_IDS, + activeChainId: ACTIVE_CHAIN_ID, + sessionTimeoutMs: WALLETCONNECT_SESSION_TIMEOUT_MS, + signTimeoutMs: WALLETCONNECT_SIGN_TIMEOUT_MS, + retry: WALLETCONNECT_RETRY_POLICY, +} as const; + export const config = { network: STELLAR_NETWORK, rpcUrl: RPC_URL, contractId, + walletConnect: walletConnectConfig, } as const; -export default config; +export default config; \ No newline at end of file diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 39152421..a1209eff 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -2,6 +2,9 @@ export type ErrorKind = | "wallet_not_connected" | "invoice_not_found" | "rpc_timeout" + | "walletconnect_timeout" + | "walletconnect_rejected" + | "walletconnect_session_expired" | "unknown"; export interface ClassifiedError { @@ -25,6 +28,25 @@ const PATTERNS: { kind: ErrorKind; match: RegExp; message: string }[] = [ match: /rpc timeout|timed out|timeout/i, message: "The Stellar RPC request timed out. Please try again.", }, + { + kind: "walletconnect_timeout", + match: + /walletconnect.*(timed out|timeout|proposal expired|session proposal)|session proposal.*(timed out|expired)|pairing.*(timed out|expired)|relay.*(timed out|timeout|disconnected|unavailable)/i, + message: + "WalletConnect timed out waiting for your mobile wallet. Check your connection and scan the QR code again.", + }, + { + kind: "walletconnect_rejected", + match: + /user rejected|user denied|rejected by user|request rejected|signature rejected|signing rejected|user cancelled|user canceled/i, + message: "The request was rejected in your mobile wallet. Approve it to continue.", + }, + { + kind: "walletconnect_session_expired", + match: + /session not found|session expired|no matching session|session topic.*(missing|unknown)|invalid session/i, + message: "Your WalletConnect session has expired. Reconnect your mobile wallet to continue.", + }, ]; export function classifyError(error: unknown): ClassifiedError { diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index c9edfed6..1774a53a 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -7,3 +7,137 @@ export function formatXLM(amount: bigint): string { export function stroopsToXLM(stroops: number): string { return (stroops / 10_000_000).toFixed(7); } + +/** + * Default number of decimal places used per asset when no explicit precision + * is provided. Stellar native assets use 7 decimals (stroops); most issued + * tokens use 7 as well, but callers can override per asset. + */ +export const DEFAULT_ASSET_DECIMALS = 7; + +/** + * Default low-balance threshold (in whole asset units) used when no + * configuration is supplied for an asset. + */ +export const DEFAULT_LOW_BALANCE_THRESHOLD = 1; + +export interface AssetBalance { + /** Asset code, e.g. "XLM", "USDC". */ + assetCode: string; + /** Raw balance in the asset's smallest unit (e.g. stroops for XLM). */ + amount: bigint; + /** Number of decimal places for this asset. Defaults to 7. */ + decimals?: number; +} + +export interface LowBalanceThresholds { + /** Threshold applied to any asset without a specific override. */ + default?: number; + /** Per-asset-code threshold overrides, in whole asset units. */ + perAsset?: Record; +} + +export interface LowBalanceWarning { + assetCode: string; + /** Formatted balance, e.g. "0.5000000". */ + formattedBalance: string; + /** Threshold (whole units) that was breached. */ + threshold: number; + /** True when the balance is at or below the threshold. */ + isLow: boolean; +} + +/** + * Format a raw asset amount (smallest unit) into a human-readable string with + * per-asset precision. + */ +export function formatAssetAmount( + amount: bigint, + assetCode: string, + decimals: number = DEFAULT_ASSET_DECIMALS, +): string { + const safeDecimals = Number.isFinite(decimals) && decimals >= 0 ? Math.floor(decimals) : DEFAULT_ASSET_DECIMALS; + const negative = amount < 0n; + const abs = negative ? -amount : amount; + const divisor = 10n ** BigInt(safeDecimals); + const whole = abs / divisor; + const fraction = abs % divisor; + + if (safeDecimals === 0) { + return `${negative ? "-" : ""}${whole.toString()}`; + } + + const fractionStr = fraction.toString().padStart(safeDecimals, "0"); + return `${negative ? "-" : ""}${whole.toString()}.${fractionStr}`; +} + +/** + * Format a raw asset amount and append the asset code, e.g. "12.5000000 USDC". + */ +export function formatBalanceWithCode( + amount: bigint, + assetCode: string, + decimals: number = DEFAULT_ASSET_DECIMALS, +): string { + return `${formatAssetAmount(amount, assetCode, decimals)} ${assetCode}`; +} + +/** + * Convert a raw asset amount (smallest unit) to a JS number in whole units. + * Intended for threshold comparisons where exact precision is not critical. + */ +export function assetAmountToNumber( + amount: bigint, + decimals: number = DEFAULT_ASSET_DECIMALS, +): number { + const safeDecimals = Number.isFinite(decimals) && decimals >= 0 ? Math.floor(decimals) : DEFAULT_ASSET_DECIMALS; + return Number(amount) / 10 ** safeDecimals; +} + +/** + * Resolve the low-balance threshold (in whole units) for a given asset. + */ +export function getLowBalanceThreshold( + assetCode: string, + thresholds?: LowBalanceThresholds, +): number { + const override = thresholds?.perAsset?.[assetCode]; + if (typeof override === "number" && Number.isFinite(override)) { + return override; + } + if (typeof thresholds?.default === "number" && Number.isFinite(thresholds.default)) { + return thresholds.default; + } + return DEFAULT_LOW_BALANCE_THRESHOLD; +} + +/** + * Compute a low-balance warning for a single asset balance. + */ +export function computeLowBalanceWarning( + balance: AssetBalance, + thresholds?: LowBalanceThresholds, +): LowBalanceWarning { + const decimals = balance.decimals ?? DEFAULT_ASSET_DECIMALS; + const threshold = getLowBalanceThreshold(balance.assetCode, thresholds); + const value = assetAmountToNumber(balance.amount, decimals); + return { + assetCode: balance.assetCode, + formattedBalance: formatAssetAmount(balance.amount, balance.assetCode, decimals), + threshold, + isLow: value <= threshold, + }; +} + +/** + * Compute low-balance warnings for a list of asset balances. Returns only the + * assets whose balances are at or below their configured thresholds. + */ +export function getLowBalanceWarnings( + balances: AssetBalance[], + thresholds?: LowBalanceThresholds, +): LowBalanceWarning[] { + return balances + .map((balance) => computeLowBalanceWarning(balance, thresholds)) + .filter((warning) => warning.isLow); +} \ No newline at end of file diff --git a/src/lib/freighter.ts b/src/lib/freighter.ts index 2a37614e..d58b3609 100644 --- a/src/lib/freighter.ts +++ b/src/lib/freighter.ts @@ -12,20 +12,10 @@ export const NETWORK_PASSPHRASE = export type WalletType = "freighter" | "walletconnect"; -let walletConnectKit: WalletConnectKit | null = null; - -/** Initialize WalletConnect kit */ -async function initWalletConnectKit() { - if (walletConnectKit) return walletConnectKit; +/** Lazily load the WalletConnect v2 service (browser only). */ +async function getWalletConnectService() { if (typeof window === "undefined") throw new Error("Browser only"); - - try { - const { getStellarWalletKit } = await import("@/lib/walletconnect-kit-stub"); - walletConnectKit = getStellarWalletKit() as any; - return walletConnectKit; - } catch { - throw new Error("WalletConnect kit not available"); - } + return import("@/lib/walletconnect"); } /** Connect Freighter and return the user's public key. */ @@ -53,11 +43,8 @@ export async function signWithFreighter(xdr: string): Promise { export async function connectWalletConnect(): Promise<{ publicKey: string; uri: string }> { if (typeof window === "undefined") throw new Error("Browser only"); - const kit = await initWalletConnectKit(); - if (!kit) throw new Error("WalletConnect kit not initialized"); - const { uri, publicKey } = await (kit as any).connect(); - - return { publicKey, uri }; + const { connectWalletConnect: connect } = await getWalletConnectService(); + return connect(); } /** Get the WalletConnect public key if connected. */ @@ -65,9 +52,8 @@ export async function getWalletConnectPublicKey(): Promise { if (typeof window === "undefined") throw new Error("Browser only"); try { - const kit = await initWalletConnectKit(); - if (!kit) return null; - return (kit as any).getPublicKey() || null; + const { getWalletConnectPublicKey: getKey } = await getWalletConnectService(); + return getKey(); } catch { return null; } @@ -77,9 +63,8 @@ export async function getWalletConnectPublicKey(): Promise { export async function signWithWalletConnect(xdr: string): Promise { if (typeof window === "undefined") throw new Error("Browser only"); - const kit = await initWalletConnectKit(); - if (!kit) throw new Error("WalletConnect kit not initialized"); - return (kit as any).signTransaction(xdr, NETWORK_PASSPHRASE); + const { signWithWalletConnect: sign } = await getWalletConnectService(); + return sign(xdr, NETWORK_PASSPHRASE); } /** Disconnect WalletConnect. */ @@ -87,9 +72,8 @@ export async function disconnectWalletConnect(): Promise { if (typeof window === "undefined") throw new Error("Browser only"); try { - const kit = await initWalletConnectKit(); - if (!kit) return; - await (kit as any).disconnect(); + const { disconnectWalletConnect: disconnect } = await getWalletConnectService(); + await disconnect(); } catch { // Ignore errors on disconnect } diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts index 1da42f57..ea96d054 100644 --- a/src/lib/stellar.ts +++ b/src/lib/stellar.ts @@ -283,18 +283,100 @@ export async function fetchUsdcBalance( } /** - * Resolve a federation address (e.g., alice*example.com) to a Stellar address + * Validate a Stellar account address using StrKey. + * Returns true only for well-formed, checksum-valid ed25519 public keys. */ -export async function resolveFederationAddress(federationAddress: string): Promise { +export function validateStellarAddress(address: string): boolean { + if (typeof address !== "string" || address.length === 0) return false; + try { + // StrKey is a synchronous part of the SDK; require it lazily to keep the + // module load-time footprint small. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { StrKey } = require("@stellar/stellar-sdk") as typeof import("@stellar/stellar-sdk"); + return StrKey.isValidEd25519PublicKey(address); + } catch { + return false; + } +} + +export const FEDERATION_SERVER_URL = + process.env.NEXT_PUBLIC_FEDERATION_SERVER_URL ?? "https://federation.stellar.org"; +export const FEDERATION_TIMEOUT_MS = 8000; + +/** + * Resolve a federation address (e.g., alice*example.com) to a Stellar address. + * + * Uses the configured federation server URL and a bounded timeout so a slow or + * unreachable server cannot hang the UI. Returns null on any failure. + */ +export async function resolveFederationAddress( + federationAddress: string, + options: { serverUrl?: string; timeoutMs?: number } = {} +): Promise { + if (typeof federationAddress !== "string" || !federationAddress.includes("*")) { + return null; + } + + const serverUrl = options.serverUrl ?? FEDERATION_SERVER_URL; + const timeoutMs = options.timeoutMs ?? FEDERATION_TIMEOUT_MS; + try { const { Federation } = await import("@stellar/stellar-sdk"); - const result = await Federation.Server.resolve(federationAddress); - return result.account_id; + const server = new Federation.Server(serverUrl); + const result = await Promise.race([ + server.resolve(federationAddress), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Federation resolution timed out.")), timeoutMs) + ), + ]); + return result.account_id ?? null; } catch { return null; } } +export interface AccountBalance { + /** Asset code, or "XLM" for the native asset. */ + assetCode: string; + /** Issuer account for issued assets; null for native XLM. */ + assetIssuer: string | null; + /** Raw balance in stroops. */ + raw: bigint; + /** Human-readable balance (7 decimal places). */ + formatted: string; + /** Whether this is the native XLM asset. */ + isNative: boolean; +} + +/** + * Fetch all balances (native XLM plus issued assets) for an account from Horizon. + * + * Throws if the account cannot be loaded (e.g. unfunded or invalid address). + */ +export async function fetchAccountBalances(walletAddress: string): Promise { + const { Horizon } = await import("@stellar/stellar-sdk"); + const server = new Horizon.Server(HORIZON_URL); + const account = await server.loadAccount(walletAddress); + + return account.balances.map((balance) => { + const isNative = balance.asset_type === "native"; + const assetCode = isNative ? "XLM" : (balance as { asset_code?: string }).asset_code ?? ""; + const assetIssuer = isNative + ? null + : (balance as { asset_issuer?: string }).asset_issuer ?? null; + const raw = BigInt( + Math.round(parseFloat(balance.balance) * 1e7) + ); + return { + assetCode, + assetIssuer, + raw, + formatted: formatAmount(raw), + isNative, + }; + }); +} + /** * Validate a Stellar address and check if it's funded and requires memo */ diff --git a/src/lib/walletconnect.ts b/src/lib/walletconnect.ts new file mode 100644 index 00000000..44ea60ac --- /dev/null +++ b/src/lib/walletconnect.ts @@ -0,0 +1,545 @@ +import { Core } from '@walletconnect/core'; +import { Web3Wallet, IWeb3Wallet } from '@walletconnect/web3wallet'; +import { getSdkError } from '@walletconnect/utils'; +import type { SessionTypes } from '@walletconnect/types'; + +/** + * WalletConnect v2 service module. + * + * Wraps @walletconnect/core and @walletconnect/web3wallet to provide: + * - pairing proposal creation (returns a URI for QR display) + * - session approval / management / persistence + * - signing request handling for Stellar transactions + * - timeout + retry wrappers around proposals and signing + * - an event emitter for connection status + */ + +export type WalletConnectStatus = + | 'idle' + | 'initializing' + | 'connecting' + | 'connected' + | 'signing' + | 'disconnected' + | 'error'; + +export interface WalletConnectStatusEvent { + status: WalletConnectStatus; + message?: string; + error?: Error; + topic?: string; + publicKey?: string; +} + +export interface ConnectResult { + uri: string; + publicKey: string; +} + +export interface SignResult { + signedXdr: string; + publicKey: string; +} + +export interface WalletConnectOptions { + projectId: string; + metadata?: { + name: string; + description: string; + url: string; + icons: string[]; + }; + /** Timeout in ms for session proposals. Defaults to 120000 (2 min). */ + proposalTimeoutMs?: number; + /** Timeout in ms for signing requests. Defaults to 120000 (2 min). */ + signTimeoutMs?: number; + /** Number of retry attempts for proposals/signing. Defaults to 2. */ + maxRetries?: number; + /** Base delay in ms between retries (exponential backoff). Defaults to 1000. */ + retryDelayMs?: number; +} + +const STELLAR_CHAIN = 'stellar:pubnet'; +const STELLAR_METHODS = ['stellar_signXDR', 'stellar_signAndSubmitXDR']; +const STELLAR_EVENTS = ['accountsChanged']; + +const DEFAULT_METADATA = { + name: 'Stellar WalletConnect', + description: 'WalletConnect v2 integration for Stellar mobile wallets', + url: 'https://stellar.org', + icons: ['https://stellar.org/favicon.ico'], +}; + +type StatusListener = (event: WalletConnectStatusEvent) => void; + +class WalletConnectEmitter { + private listeners: Set = new Set(); + + on(listener: StatusListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + off(listener: StatusListener): void { + this.listeners.delete(listener); + } + + emit(event: WalletConnectStatusEvent): void { + this.listeners.forEach((listener) => { + try { + listener(event); + } catch { + // Listener errors must not break the emitter. + } + }); + } +} + +export const walletConnectEvents = new WalletConnectEmitter(); + +let web3wallet: IWeb3Wallet | null = null; +let options: WalletConnectOptions | null = null; +let currentStatus: WalletConnectStatus = 'idle'; +let activeTopic: string | null = null; +let activePublicKey: string | null = null; +let initPromise: Promise | null = null; + +function setStatus( + status: WalletConnectStatus, + extra: Omit = {}, +): void { + currentStatus = status; + walletConnectEvents.emit({ status, ...extra }); +} + +export function getWalletConnectStatus(): WalletConnectStatus { + return currentStatus; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +async function withRetry( + fn: () => Promise, + maxRetries: number, + retryDelayMs: number, + label: string, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (attempt < maxRetries) { + await delay(retryDelayMs * Math.pow(2, attempt)); + } + } + } + throw lastError instanceof Error + ? lastError + : new Error(`${label} failed after ${maxRetries + 1} attempts`); +} + +function extractPublicKey(session: SessionTypes.Struct): string | null { + const namespaces = session.namespaces ?? {}; + for (const key of Object.keys(namespaces)) { + const accounts = namespaces[key]?.accounts ?? []; + for (const account of accounts) { + // Format: stellar:pubnet:G... + const parts = account.split(':'); + const address = parts[parts.length - 1]; + if (address && address.startsWith('G')) { + return address; + } + } + } + return null; +} + +function findSessionByTopic(topic: string): SessionTypes.Struct | null { + if (!web3wallet) return null; + const sessions = web3wallet.getActiveSessions(); + return sessions[topic] ?? null; +} + +function getActiveSession(): SessionTypes.Struct | null { + if (!web3wallet) return null; + const sessions = web3wallet.getActiveSessions(); + const topics = Object.keys(sessions); + if (topics.length === 0) return null; + + if (activeTopic && sessions[activeTopic]) { + return sessions[activeTopic]; + } + + // Fall back to the most recently created session. + const sorted = topics + .map((topic) => sessions[topic]) + .sort((a, b) => (b.expiry ?? 0) - (a.expiry ?? 0)); + return sorted[0] ?? null; +} + +/** + * Initialize the WalletConnect v2 client. Safe to call multiple times; the + * underlying client is created once and reused. + */ +export async function initWalletConnect(opts: WalletConnectOptions): Promise { + if (web3wallet) { + return web3wallet; + } + if (initPromise) { + return initPromise; + } + + options = { + proposalTimeoutMs: 120000, + signTimeoutMs: 120000, + maxRetries: 2, + retryDelayMs: 1000, + ...opts, + }; + + setStatus('initializing'); + + initPromise = (async () => { + try { + const core = new Core({ projectId: options!.projectId }); + const client = await Web3Wallet.init({ + core, + metadata: options!.metadata ?? DEFAULT_METADATA, + }); + + client.on('session_proposal', async (proposal) => { + try { + const requiredNamespaces = proposal.params.requiredNamespaces ?? {}; + const stellarRequired = requiredNamespaces[STELLAR_CHAIN]; + + if (!stellarRequired) { + await client.rejectSession({ + id: proposal.id, + reason: getSdkError('UNSUPPORTED_CHAINS'), + }); + setStatus('error', { + message: 'Unsupported chain in session proposal', + }); + return; + } + + const publicKey = activePublicKey; + if (!publicKey) { + await client.rejectSession({ + id: proposal.id, + reason: getSdkError('USER_REJECTED'), + }); + setStatus('error', { + message: 'No public key available to approve session', + }); + return; + } + + const namespaces = { + [STELLAR_CHAIN]: { + accounts: [`${STELLAR_CHAIN}:${publicKey}`], + methods: STELLAR_METHODS, + events: STELLAR_EVENTS, + }, + }; + + const session = await client.approveSession({ + id: proposal.id, + namespaces, + }); + + activeTopic = session.topic; + setStatus('connected', { + topic: session.topic, + publicKey, + }); + } catch (error) { + setStatus('error', { + message: 'Failed to handle session proposal', + error: error instanceof Error ? error : new Error(String(error)), + }); + } + }); + + client.on('session_request', async (event) => { + const { topic, params, id } = event; + const { request } = params; + + try { + const session = findSessionByTopic(topic); + if (!session) { + await client.respondSessionRequest({ + topic, + response: { + id, + jsonrpc: '2.0', + error: getSdkError('SESSION_SETTLEMENT_FAILED'), + }, + }); + return; + } + + if (request.method === 'stellar_signXDR' || request.method === 'stellar_signAndSubmitXDR') { + // Signing is handled by the dApp side via signWithWalletConnect. + // Mobile wallets respond through their own handler; here we simply + // acknowledge unsupported inbound signing requests. + await client.respondSessionRequest({ + topic, + response: { + id, + jsonrpc: '2.0', + error: getSdkError('UNSUPPORTED_METHODS'), + }, + }); + return; + } + + await client.respondSessionRequest({ + topic, + response: { + id, + jsonrpc: '2.0', + error: getSdkError('UNSUPPORTED_METHODS'), + }, + }); + } catch (error) { + setStatus('error', { + message: 'Failed to handle session request', + error: error instanceof Error ? error : new Error(String(error)), + }); + } + }); + + client.on('session_delete', (event) => { + if (activeTopic === event.topic) { + activeTopic = null; + activePublicKey = null; + } + setStatus('disconnected', { topic: event.topic }); + }); + + web3wallet = client; + setStatus('idle'); + return client; + } catch (error) { + initPromise = null; + setStatus('error', { + message: 'Failed to initialize WalletConnect', + error: error instanceof Error ? error : new Error(String(error)), + }); + throw error; + } + })(); + + return initPromise; +} + +/** + * Create a pairing proposal and return the URI for QR display along with the + * public key that will be used to approve the session. + * + * The public key is provided by the caller (the wallet account that will sign). + */ +export async function connectWalletConnect( + publicKey: string, + opts?: Partial, +): Promise { + if (!publicKey || !publicKey.startsWith('G')) { + throw new Error('A valid Stellar public key is required to connect'); + } + + if (!web3wallet) { + if (!opts?.projectId && !options?.projectId) { + throw new Error('WalletConnect projectId is required'); + } + await initWalletConnect({ + projectId: opts?.projectId ?? options!.projectId, + ...options, + ...opts, + } as WalletConnectOptions); + } + + const client = web3wallet!; + const proposalTimeoutMs = options?.proposalTimeoutMs ?? 120000; + const maxRetries = options?.maxRetries ?? 2; + const retryDelayMs = options?.retryDelayMs ?? 1000; + + activePublicKey = publicKey; + setStatus('connecting', { publicKey }); + + const uri = await withRetry( + async () => { + const { uri: pairingUri } = await withTimeout( + client.pair({ + uri: undefined as unknown as string, + }).catch(() => { + // pair() with undefined uri is not supported; use core pairing below. + throw new Error('pairing requires a uri'); + }), + proposalTimeoutMs, + 'Session proposal', + ); + return pairingUri; + }, + 0, + retryDelayMs, + 'Session proposal', + ).catch(async () => { + // Fallback: create a pairing topic directly via the core client. + const core = (client as unknown as { core: { pairing: { create: () => Promise<{ uri: string }> } } }).core; + const pairing = await withRetry( + () => withTimeout(core.pairing.create(), proposalTimeoutMs, 'Pairing creation'), + maxRetries, + retryDelayMs, + 'Pairing creation', + ); + return pairing.uri; + }); + + return { uri, publicKey }; +} + +/** + * Return the public key associated with the active WalletConnect session. + */ +export function getWalletConnectPublicKey(): string | null { + if (activePublicKey) { + return activePublicKey; + } + const session = getActiveSession(); + if (!session) { + return null; + } + const publicKey = extractPublicKey(session); + if (publicKey) { + activePublicKey = publicKey; + activeTopic = session.topic; + } + return publicKey; +} + +/** + * Send a signing request for a Stellar transaction XDR to the connected mobile + * wallet and return the signed XDR. + */ +export async function signWithWalletConnect(xdr: string): Promise { + if (!web3wallet) { + throw new Error('WalletConnect is not initialized'); + } + if (!xdr) { + throw new Error('An XDR string is required to sign'); + } + + const session = getActiveSession(); + if (!session) { + throw new Error('No active WalletConnect session'); + } + + const publicKey = extractPublicKey(session) ?? activePublicKey; + if (!publicKey) { + throw new Error('Unable to determine public key for active session'); + } + + const client = web3wallet; + const signTimeoutMs = options?.signTimeoutMs ?? 120000; + const maxRetries = options?.maxRetries ?? 2; + const retryDelayMs = options?.retryDelayMs ?? 1000; + + setStatus('signing', { topic: session.topic, publicKey }); + + try { + const signedXdr = await withRetry( + async () => { + const response = await withTimeout( + client.request({ + topic: session.topic, + chainId: STELLAR_CHAIN, + request: { + method: 'stellar_signXDR', + params: { xdr }, + }, + }), + signTimeoutMs, + 'Signing request', + ); + + const result = response as { signedXDR?: string; signedXdr?: string }; + const signed = result.signedXDR ?? result.signedXdr; + if (!signed) { + throw new Error('Wallet did not return a signed XDR'); + } + return signed; + }, + maxRetries, + retryDelayMs, + 'Signing request', + ); + + setStatus('connected', { topic: session.topic, publicKey }); + return { signedXdr, publicKey }; + } catch (error) { + setStatus('error', { + message: 'Signing failed', + error: error instanceof Error ? error : new Error(String(error)), + topic: session.topic, + publicKey, + }); + throw error; + } +} + +/** + * Disconnect the active WalletConnect session (if any) and reset local state. + */ +export async function disconnectWalletConnect(): Promise { + const client = web3wallet; + const session = getActiveSession(); + + if (client && session) { + try { + await client.disconnectSession({ + topic: session.topic, + reason: getSdkError('USER_DISCONNECTED'), + }); + } catch { + // Ignore disconnect errors; local state is reset regardless. + } + } + + activeTopic = null; + activePublicKey = null; + setStatus('disconnected'); +} + +export default { + initWalletConnect, + connectWalletConnect, + getWalletConnectPublicKey, + signWithWalletConnect, + disconnectWalletConnect, + getWalletConnectStatus, + walletConnectEvents, +}; \ No newline at end of file