diff --git a/.env.local.example b/.env.local.example
index dc002af..5ce48d8 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 48393fa..681708b 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 */}
+
+
setPanelOpen(true)}
className="group flex items-center gap-2 rounded-sm border border-violet-700/40 bg-violet-950/30 px-3 py-1.5 hover:border-violet-500/60 transition-colors"
title={address}
+ aria-label={`Open profile for ${truncateAddress(address)}`}
>
-
+ {/* Status dot — amber for hardware wallet, blue for WC, violet default */}
+
{t.walletLabel} · {address.slice(0, 4)}
@@ -63,7 +144,7 @@ export function FreighterConnect({ trailing }: FreighterConnectProps) {
disabled={connecting}
className="border border-border/80 bg-background/75 backdrop-blur-md px-3 py-2 font-mono text-[10px] uppercase tracking-widest text-muted-foreground hover:text-foreground hover:border-accent transition-colors disabled:opacity-50 shadow-sm"
>
- {connecting ? t.connecting : t.connect}
+ {connectingLabel}
)}
{trailing != null ? (
@@ -83,3 +164,4 @@ export function FreighterConnect({ trailing }: FreighterConnectProps) {
)
}
+
diff --git a/components/wallet-provider.tsx b/components/wallet-provider.tsx
index 5925c16..aa23bd6 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 }: { payload: any }) => {
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
@@ -267,16 +455,19 @@ export function WalletProvider({ children }: { children: ReactNode }) {
throw new Error("authModal not available")
}
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)
- if (pe.code !== "-1") {
+ stopHeartbeat()
+ if (pe.code !== -1) {
setHint(pe.message || "Wallet connection failed")
}
} finally {
@@ -286,9 +477,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
@@ -300,13 +492,17 @@ export function WalletProvider({ children }: { children: ReactNode }) {
.authModal()
.then(({ address: next }: { address: any }) => {
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) => {
@@ -316,17 +512,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(
() => ({
@@ -335,6 +537,8 @@ export function WalletProvider({ children }: { children: ReactNode }) {
hint,
artistAlias,
aliasLoading,
+ isHardwareWallet,
+ isWalletConnect,
connect,
disconnect,
refresh,
@@ -348,6 +552,8 @@ export function WalletProvider({ children }: { children: ReactNode }) {
hint,
artistAlias,
aliasLoading,
+ isHardwareWallet,
+ isWalletConnect,
connect,
disconnect,
refresh,
@@ -357,10 +563,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}
+
+
+
void validateHardwareNetwork().catch(() => {})}
+ className="shrink-0 rounded-md border border-red-500/50 bg-red-500/10 px-3 py-1.5 font-mono text-xs font-semibold text-red-300 hover:bg-red-500/20"
+ >
+ Reintentar / Retry
+
+
+
+ )}
+
+ {/* ── 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 } }
}
}