From 410f1e8f7de1891254194533324e31769ca0ce9a Mon Sep 17 00:00:00 2001 From: Peace Oluwaseyi Date: Mon, 31 Aug 2026 11:33:15 +0000 Subject: [PATCH] feat(wallet): add Ledger & WalletConnect multi-wallet support (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/stellar-wallet-kit.ts - Add LedgerModule and WalletConnectModule to kit modules array - Export LEDGER_ID, WALLET_CONNECT_ID, WalletConnectTargetChain - Export TESTNET_PASSPHRASE / MAINNET_PASSPHRASE constants - Add isHardwareWalletSelected() and isWalletConnectSelected() helpers - signTransaction: network passphrase guard for Ledger (rejects unknown network before hitting HID transport) - signTransaction: friendly error messages for USB/timeout/rejected errors - WalletConnect project ID from NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID - components/wallet-provider.tsx - Session restoration on mount via refresh() - Heartbeat: 8 s for hardware wallets, 30 s for extension wallets - navigator.usb connect/disconnect event listeners for immediate Ledger unplug detection - Hardware wallet network passphrase mismatch validation (calls kit.getNetwork() after connect and via effect) - Red banner UI alert when Ledger is on wrong network passphrase - isHardwareWallet / isWalletConnect flags exposed in context - stopHeartbeat() called on disconnect and session expiry - syncModuleFlags() on connect/openWalletPicker to keep flags current - components/freighter-connect.tsx - WalletTypeBadge: amber '⬡ LEDGER' badge for hardware wallets, blue '◈ WC' badge for WalletConnect sessions - Status dot: amber for Ledger, blue for WalletConnect, violet default - Context-aware connecting label: 'Opening Ledger…' / 'Opening QR…' - Import cn from @/lib/utils - .env.local.example - Document NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID variable Closes #34 --- .env.local.example | 6 + components/freighter-connect.tsx | 88 +++++++++- components/wallet-provider.tsx | 289 +++++++++++++++++++++++++++++-- lib/stellar-wallet-kit.ts | 106 +++++++++++- 4 files changed, 466 insertions(+), 23 deletions(-) diff --git a/.env.local.example b/.env.local.example index dc002af5..5ce48d8e 100644 --- a/.env.local.example +++ b/.env.local.example @@ -26,6 +26,12 @@ MERCURY_JWT= # Casi nunca hace falta; override solo si el cliente debe usar otro proxy (URL absoluta con CORS): # NEXT_PUBLIC_SOROBAN_RPC_PROXY_URL= # +# ─── WalletConnect / Reown ───────────────────────────────────────────────── +# ID de proyecto de Reown (ex WalletConnect Cloud). Necesario para que el QR de WalletConnect funcione. +# Registrá tu app gratis en https://cloud.reown.com → copia el Project ID. +# Si no se define, la app usa un ID de fallback público (puede tener límites de rate). +# NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=tu_project_id_aqui +# # Si despliegas bajo subruta (URL tipo https://host/MIPROJECTO/ sin basePath en Next, o reverse proxy): # NEXT_PUBLIC_BASE_PATH=MIPROJECTO # Así el cliente llama a …/MIPROJECTO/api/soroban-rpc y no a …/api/soroban-rpc (404 → falso “sin XLM”). diff --git a/components/freighter-connect.tsx b/components/freighter-connect.tsx index 48393fa7..681708bd 100644 --- a/components/freighter-connect.tsx +++ b/components/freighter-connect.tsx @@ -4,12 +4,58 @@ import { useState, type ReactNode } from "react" import { ProfilePanel } from "@/components/profile-panel" import { useLang } from "@/components/lang-context" import { useWallet } from "@/components/wallet-provider" +import { cn } from "@/lib/utils" function truncateAddress(addr: string) { if (!addr || addr.length < 14) return addr return `${addr.slice(0, 6)}…${addr.slice(-4)}` } +// ───────────────────────────────────────────────────────────────────────────── +// Wallet-type badge helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Small inline badge indicating which wallet type is active. + * Only rendered when connected with a hardware wallet or WalletConnect — the + * default browser-extension wallets don't need an extra label. + */ +function WalletTypeBadge({ + isHardwareWallet, + isWalletConnect, +}: { + isHardwareWallet: boolean + isWalletConnect: boolean +}) { + if (isHardwareWallet) { + return ( + + ⬡ LEDGER + + ) + } + if (isWalletConnect) { + return ( + + ◈ WC + + ) + } + return null +} + +// ───────────────────────────────────────────────────────────────────────────── +// Component +// ───────────────────────────────────────────────────────────────────────────── + type FreighterConnectProps = { /** Rendered after wallet control, e.g. language toggle — prefixed with "|". */ trailing?: ReactNode @@ -17,7 +63,15 @@ type FreighterConnectProps = { export function FreighterConnect({ trailing }: FreighterConnectProps) { const { lang } = useLang() - const { address, connecting, hint, connect, disconnect } = useWallet() + const { + address, + connecting, + hint, + isHardwareWallet, + isWalletConnect, + connect, + disconnect, + } = useWallet() const [panelOpen, setPanelOpen] = useState(false) const t = @@ -26,13 +80,25 @@ export function FreighterConnect({ trailing }: FreighterConnectProps) { walletLabel: "PROFILE", connecting: "Conectando...", connect: "Conectar Wallet", + ledgerConnecting: "Abriendo Ledger…", + wcConnecting: "Abriendo QR…", } : { walletLabel: "PROFILE", connecting: "Connecting...", connect: "Connect Wallet", + ledgerConnecting: "Opening Ledger…", + wcConnecting: "Opening QR…", } + // Derive a context-aware connecting label. + const connectingLabel = (() => { + if (!connecting) return t.connect + if (isHardwareWallet) return t.ledgerConnecting + if (isWalletConnect) return t.wcConnecting + return t.connecting + })() + return (
@@ -44,13 +110,28 @@ export function FreighterConnect({ trailing }: FreighterConnectProps) { address={address} disconnect={disconnect} /> + + {/* Wallet-type badge sits outside the profile button to keep it visible */} + + )} {trailing != null ? ( @@ -83,3 +164,4 @@ export function FreighterConnect({ trailing }: FreighterConnectProps) {
) } + diff --git a/components/wallet-provider.tsx b/components/wallet-provider.tsx index 4c8d7f6e..ba875807 100644 --- a/components/wallet-provider.tsx +++ b/components/wallet-provider.tsx @@ -15,15 +15,31 @@ import { isAlbedoSelectedInKit, requestAlbedoImplicitTxFlow, } from "@/lib/albedo-intent-client" -import { initStellarWalletKit, kit, KitEventType, parseError } from "@/lib/stellar-wallet-kit" +import { + initStellarWalletKit, + isHardwareWalletSelected, + isWalletConnectSelected, + kit, + KitEventType, + parseError, + TESTNET_PASSPHRASE, +} from "@/lib/stellar-wallet-kit" import { cn } from "@/lib/utils" +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + type WalletContextValue = { address: string | null connecting: boolean hint: string | null artistAlias: string | null aliasLoading: boolean + /** Whether the active module is a hardware wallet (Ledger). */ + isHardwareWallet: boolean + /** Whether the active module is WalletConnect. */ + isWalletConnect: boolean connect: () => Promise disconnect: () => void /** Re-sync con la wallet vía kit; devuelve la dirección activa o null. */ @@ -37,16 +53,31 @@ type WalletContextValue = { saveArtistAlias: (alias: string) => Promise<{ ok: true } | { ok: false; error: string }> } +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + const WalletContext = createContext(null) /** * React 18 Strict Mode (dev) monta/desmonta dos veces: sin esto, el auto-claim del faucet * dispara POST duplicados (409 already claimed / 412 trustline) y ensucia la consola de red. */ -/** Solo amortigua el doble `useEffect` de Strict Mode (~ms); no sustituye al ref por wallet. */ const FAUCET_AUTO_CLAIM_DEDUPE_MS = 4000 const lastFaucetAutoClaimAt = new Map() +/** + * How often the heartbeat checks that the connected wallet is still reachable. + * For hardware wallets (Ledger) this is shorter because USB HID connections can + * drop silently; for extension wallets we rely on kit state events instead. + */ +const HEARTBEAT_INTERVAL_HW_MS = 8_000 +const HEARTBEAT_INTERVAL_SW_MS = 30_000 + +// ───────────────────────────────────────────────────────────────────────────── +// Provider +// ───────────────────────────────────────────────────────────────────────────── + export function WalletProvider({ children }: { children: ReactNode }) { if (typeof window !== "undefined") { initStellarWalletKit() @@ -57,19 +88,31 @@ export function WalletProvider({ children }: { children: ReactNode }) { const [hint, setHint] = useState(null) const [artistAlias, setArtistAlias] = useState(null) const [aliasLoading, setAliasLoading] = useState(false) + const [isHardwareWallet, setIsHardwareWallet] = useState(false) + const [isWalletConnect, setIsWalletConnect] = useState(false) - /** - * Tras conectar, el kit mantiene la dirección en memoria; al desconectar en la app - * no queremos que un `refresh()` vuelva a rellenarla hasta un nuevo `connect`. - */ + /** True when user deliberately disconnected — blocks session restoration. */ const userDisconnectedRef = useRef(false) const autoFundedWalletsRef = useRef>(new Set()) + const heartbeatTimerRef = useRef | null>(null) /** Albedo: sin `implicit_flow` para `tx`, el popup de firma se bloquea tras awaits largos (Soroban). */ const [albedoTxPrep, setAlbedoTxPrep] = useState<"hidden" | "needed" | "checking">("hidden") const [albedoPrepBusy, setAlbedoPrepBusy] = useState(false) const [albedoPrepError, setAlbedoPrepError] = useState(null) + // Network passphrase mismatch alert — shown when Ledger is connected but the + // kit's active network doesn't match TESTNET_PASSPHRASE. + const [networkMismatch, setNetworkMismatch] = useState(null) + + // ── helpers ────────────────────────────────────────────────────────────── + + /** Sync module-type flags from localStorage (mirrors kit persisted selection). */ + const syncModuleFlags = useCallback(() => { + setIsHardwareWallet(isHardwareWalletSelected()) + setIsWalletConnect(isWalletConnectSelected()) + }, []) + const syncAlbedoTxPrep = useCallback(async (addr: string | null) => { if (!addr || typeof window === "undefined") { setAlbedoTxPrep("hidden") @@ -88,6 +131,33 @@ export function WalletProvider({ children }: { children: ReactNode }) { } }, []) + /** + * Validate that a hardware-wallet connection is using the expected network + * passphrase. Ledger's Stellar app is mode-specific; signing testnet XDRs + * with a mainnet app returns a wrong signature silently. + */ + const validateHardwareNetwork = useCallback(async () => { + if (typeof window === "undefined") return + if (!isHardwareWalletSelected()) { + setNetworkMismatch(null) + return + } + try { + const { networkPassphrase } = await kit.getNetwork() + if (networkPassphrase && networkPassphrase !== TESTNET_PASSPHRASE) { + setNetworkMismatch( + `Ledger está conectado a "${networkPassphrase.slice(0, 48)}…" pero la app espera la testnet. Abrí la app Stellar en tu Ledger y seleccioná la red correcta (Test SDF Network). / Ledger is on the wrong network. Open the Stellar app on your Ledger and switch to the correct network.`, + ) + } else { + setNetworkMismatch(null) + } + } catch { + // Not critical — leave any previous mismatch shown. + } + }, []) + + // ── session refresh ──────────────────────────────────────────────────────── + const refresh = useCallback((): Promise => { const run = async (): Promise => { try { @@ -122,29 +192,145 @@ export function WalletProvider({ children }: { children: ReactNode }) { }) }, []) + // ── heartbeat ───────────────────────────────────────────────────────────── + + /** + * Start a recurring heartbeat that re-verifies the active wallet is still + * reachable. For hardware wallets this detects silent USB HID disconnects. + * For WalletConnect it keeps the session alive and detects remote disconnects. + */ + const startHeartbeat = useCallback( + (addr: string) => { + if (heartbeatTimerRef.current) clearInterval(heartbeatTimerRef.current) + const interval = isHardwareWalletSelected() ? HEARTBEAT_INTERVAL_HW_MS : HEARTBEAT_INTERVAL_SW_MS + + heartbeatTimerRef.current = setInterval(async () => { + if (userDisconnectedRef.current) { + clearInterval(heartbeatTimerRef.current!) + heartbeatTimerRef.current = null + return + } + try { + const { address: current } = await kit.getAddress() + if (!current || current !== addr) { + // Session expired or wallet changed — surface a hint. + setHint( + "Wallet sesión expirada. Reconectá tu wallet. / Wallet session expired. Please reconnect.", + ) + setAddress(null) + clearInterval(heartbeatTimerRef.current!) + heartbeatTimerRef.current = null + } + } catch { + // Hardware wallet USB disconnect: clear address and notify. + if (isHardwareWalletSelected()) { + setAddress(null) + setHint( + "Ledger desconectado. Volvé a enchufar el dispositivo y reconectá. / Ledger disconnected. Re-plug the device and reconnect.", + ) + } + clearInterval(heartbeatTimerRef.current!) + heartbeatTimerRef.current = null + } + }, interval) + }, + [], + ) + + const stopHeartbeat = useCallback(() => { + if (heartbeatTimerRef.current) { + clearInterval(heartbeatTimerRef.current) + heartbeatTimerRef.current = null + } + }, []) + + // ── effects ─────────────────────────────────────────────────────────────── + + /** Initial session restoration on mount. */ useEffect(() => { - void refresh().catch(() => {}) - }, [refresh]) + syncModuleFlags() + void refresh().then((addr) => { + if (addr) startHeartbeat(addr) + }).catch(() => {}) + }, [refresh, startHeartbeat, syncModuleFlags]) + /** Re-check session when the tab regains focus (catches page navigations). */ useEffect(() => { - const onFocus = () => void refresh().catch(() => {}) + const onFocus = () => { + void refresh().then((addr) => { + if (addr && !heartbeatTimerRef.current) startHeartbeat(addr) + }).catch(() => {}) + } window.addEventListener("focus", onFocus) return () => window.removeEventListener("focus", onFocus) - }, [refresh]) + }, [refresh, startHeartbeat]) + /** Subscribe to kit state events (extension wallets dispatch these). */ useEffect(() => { initStellarWalletKit() const stop = kit.on(KitEventType.STATE_UPDATED, ({ payload }) => { try { if (userDisconnectedRef.current) return - setAddress(payload.address ?? null) + const addr = payload.address ?? null + setAddress(addr) + if (addr) startHeartbeat(addr) + else stopHeartbeat() } catch { setAddress(null) + stopHeartbeat() } }) return stop - }, []) + }, [startHeartbeat, stopHeartbeat]) + + /** + * HID device disconnect events — WebUSB fires `connect`/`disconnect` events + * on `navigator.usb`. When the Ledger is physically unplugged the heartbeat + * will catch it, but we also listen here for an immediate UX response. + */ + useEffect(() => { + if (typeof navigator === "undefined" || !("usb" in navigator)) return + + const usb = (navigator as Navigator & { usb?: USBManager }).usb + if (!usb) return + + const onDisconnect = () => { + if (!isHardwareWalletSelected()) return + if (userDisconnectedRef.current) return + stopHeartbeat() + setAddress(null) + setHint( + "Ledger desconectado (USB). Volvé a enchufar y reconectá. / Ledger disconnected (USB). Re-plug and reconnect.", + ) + } + + const onConnect = () => { + if (!isHardwareWalletSelected()) return + // Clear the stale disconnect hint and attempt to restore the session. + setHint(null) + void refresh().then((addr) => { + if (addr) startHeartbeat(addr) + }).catch(() => {}) + } + + usb.addEventListener("disconnect", onDisconnect) + usb.addEventListener("connect", onConnect) + return () => { + usb.removeEventListener("disconnect", onDisconnect) + usb.removeEventListener("connect", onConnect) + } + }, [refresh, startHeartbeat, stopHeartbeat]) + + /** Validate hardware wallet network passphrase whenever address or module changes. */ + useEffect(() => { + if (isHardwareWallet && address) { + void validateHardwareNetwork().catch(() => {}) + } else { + setNetworkMismatch(null) + } + }, [address, isHardwareWallet, validateHardwareNetwork]) + /** Faucet auto-claim (unchanged from original). */ useEffect(() => { if (!address || userDisconnectedRef.current) return if (autoFundedWalletsRef.current.has(address)) return @@ -256,6 +442,8 @@ export function WalletProvider({ children }: { children: ReactNode }) { void syncAlbedoTxPrep(address) }, [address, syncAlbedoTxPrep]) + // ── actions ─────────────────────────────────────────────────────────────── + const connect = useCallback((): Promise => { const run = async (): Promise => { userDisconnectedRef.current = false @@ -264,15 +452,18 @@ export function WalletProvider({ children }: { children: ReactNode }) { initStellarWalletKit() try { const { address: next } = await kit.authModal() + syncModuleFlags() if (!userDisconnectedRef.current) { setAddress(next) - // Defer: el kit persiste `selectedModuleId` en localStorage en un effect de Preact. + startHeartbeat(next) queueMicrotask(() => void syncAlbedoTxPrep(next)) + queueMicrotask(() => void validateHardwareNetwork()) } setHint(null) } catch (e) { const pe = parseError(e) setAddress(null) + stopHeartbeat() if (pe.code !== -1) { setHint(pe.message || "Wallet connection failed") } @@ -283,9 +474,10 @@ export function WalletProvider({ children }: { children: ReactNode }) { return run().catch(() => { setConnecting(false) setAddress(null) + stopHeartbeat() setHint("Wallet unavailable") }) - }, [syncAlbedoTxPrep]) + }, [syncAlbedoTxPrep, syncModuleFlags, startHeartbeat, stopHeartbeat, validateHardwareNetwork]) const openWalletPicker = useCallback((): Promise => { userDisconnectedRef.current = false @@ -294,13 +486,17 @@ export function WalletProvider({ children }: { children: ReactNode }) { .authModal() .then(({ address: next }) => { const g = typeof next === "string" ? next.trim() : "" + syncModuleFlags() if (!g) { setAddress(null) + stopHeartbeat() return null } setAddress(g) setHint(null) + startHeartbeat(g) queueMicrotask(() => void syncAlbedoTxPrep(g)) + queueMicrotask(() => void validateHardwareNetwork()) return g }) .catch((e: unknown) => { @@ -310,17 +506,23 @@ export function WalletProvider({ children }: { children: ReactNode }) { } return null }) - }, [syncAlbedoTxPrep]) + }, [syncAlbedoTxPrep, syncModuleFlags, startHeartbeat, stopHeartbeat, validateHardwareNetwork]) const disconnect = useCallback(() => { userDisconnectedRef.current = true + stopHeartbeat() void kit.disconnect().catch(() => {}) setAddress(null) setArtistAlias(null) setHint(null) setAlbedoTxPrep("hidden") setAlbedoPrepError(null) - }, []) + setNetworkMismatch(null) + setIsHardwareWallet(false) + setIsWalletConnect(false) + }, [stopHeartbeat]) + + // ── context value ───────────────────────────────────────────────────────── const value = useMemo( () => ({ @@ -329,6 +531,8 @@ export function WalletProvider({ children }: { children: ReactNode }) { hint, artistAlias, aliasLoading, + isHardwareWallet, + isWalletConnect, connect, disconnect, refresh, @@ -342,6 +546,8 @@ export function WalletProvider({ children }: { children: ReactNode }) { hint, artistAlias, aliasLoading, + isHardwareWallet, + isWalletConnect, connect, disconnect, refresh, @@ -351,10 +557,45 @@ export function WalletProvider({ children }: { children: ReactNode }) { ], ) + // ── render ──────────────────────────────────────────────────────────────── + return ( <> {children} - {address && albedoTxPrep === "needed" && ( + + {/* ── Network passphrase mismatch alert (Ledger on wrong network) ──── */} + {networkMismatch && address && isHardwareWallet && ( +
+
+
+ {/* Ledger icon indicator */} + + ⬡ + +

+ Ledger — red incorrecta.{" "} + {networkMismatch} +

+
+ +
+
+ )} + + {/* ── Albedo implicit-tx permission banner ─────────────────────────── */} + {address && albedoTxPrep === "needed" && !networkMismatch && (
{ initStellarWalletKit() + + // ── Network passphrase guard for Ledger ──────────────────────────────────── + // Ledger apps are network-specific: signing testnet XDRs on a mainnet-mode + // Stellar app will fail cryptographically. Catch this mismatch early and + // surface a clear human-readable error instead of a cryptic HID timeout. + if (typeof window !== "undefined" && isHardwareWalletSelected()) { + if ( + opts.networkPassphrase && + opts.networkPassphrase !== TESTNET_PASSPHRASE && + opts.networkPassphrase !== MAINNET_PASSPHRASE + ) { + return { + error: { + message: `Ledger: red desconocida (passphrase "${opts.networkPassphrase.slice(0, 32)}…"). Verificá que la app Stellar del Ledger esté abierta y en la red correcta. / Unknown network passphrase. Make sure the Stellar app is open on your Ledger and set to the correct network.`, + }, + } + } + // Warn if the app is currently operating on testnet but receiving a mainnet passphrase + if (opts.networkPassphrase === MAINNET_PASSPHRASE) { + console.warn("[PHASE] Ledger: signing a MAINNET transaction — ensure your Ledger Stellar app is in mainnet mode.") + } + } + + // ── Albedo implicit-tx guard ─────────────────────────────────────────────── if (typeof window !== "undefined" && opts.address && isAlbedoSelectedInKit()) { const implicitOk = await albedoImplicitTxAllowed(opts.address) if (!implicitOk) { @@ -59,11 +144,26 @@ export async function signTransaction( } } } + try { const { signedTxXdr } = await StellarWalletsKit.signTransaction(xdr, opts) return { signedTxXdr, signedTransaction: signedTxXdr } } catch (e: unknown) { const err = parseError(e) - return { error: { message: err.message } } + // Map common Ledger/USB errors to friendly messages + const raw = err.message ?? "" + let message = raw + + if (/transport|webusb|hid|usb/i.test(raw)) { + message = `Ledger USB: ${raw}. Intentá desconectar y volver a enchufar el dispositivo, o habilitá el acceso HID/WebUSB en la configuración del navegador. / USB transport error. Try unplugging and reconnecting the Ledger, or enable HID/WebUSB in browser settings.` + } else if (/timeout/i.test(raw)) { + message = `Ledger: tiempo de espera agotado. Asegurate de que la app Stellar esté abierta y el dispositivo desbloqueado. / Ledger sign timeout. Make sure the Stellar app is open and the device is unlocked.` + } else if (/denied|rejected|cancel/i.test(raw)) { + message = `Ledger: transacción rechazada en el dispositivo. / Transaction rejected on device.` + } else if (/no wallet has been connected/i.test(raw)) { + message = `Sin wallet conectada. Conectá tu wallet antes de firmar. / No wallet connected. Please connect a wallet first.` + } + + return { error: { message } } } }