From 71d3d60d09d0da8f04e3d32dafb0c1d7f4c18e7f Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 17:04:43 +0700 Subject: [PATCH] Harden wallet mints: browser-side CORS reachability probe - Add mintReachability.ts: probe a mint's /v1/info from the user's browser to detect CORS-blocked ('cors-blocked') vs down ('unreachable') vs 'ok' - useMintManager.handleAddMint: probe before adding; reject CORS-blocked/unreachable mints with a clear message instead of letting them fail later on invoice - useCashu default mint: instead of hardcoding one (unverifiable at build time), probe an ordered list of web-friendly candidates and add the first that's reachable in-browser; minibits excluded (mobile-app only) Co-authored-by: shakespeare.diy --- src/hooks/wallet/useCashu.ts | 39 ++++++++++------ src/hooks/wallet/useMintManager.ts | 15 ++++++ src/lib/mintReachability.ts | 75 ++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 14 deletions(-) create mode 100644 src/lib/mintReachability.ts diff --git a/src/hooks/wallet/useCashu.ts b/src/hooks/wallet/useCashu.ts index 01aa3f7..1141ac7 100644 --- a/src/hooks/wallet/useCashu.ts +++ b/src/hooks/wallet/useCashu.ts @@ -5,6 +5,7 @@ import { wordlist } from '@scure/bip39/wordlists/english'; import { initializeCoco, ConsoleLogger, getEncodedToken, type Repositories } from 'coco-cashu-core'; import { IndexedDbRepositories } from 'coco-cashu-indexeddb'; import type { Mint } from 'coco-cashu-core'; +import { probeMintReachability } from '@/lib/mintReachability'; // Types from coco-cashu-core type CocoManager = Awaited>; @@ -166,22 +167,32 @@ export function useCashu(): UseCashuReturn { console.warn('[Cashu] Failed to enable some coco services:', err); } - // Add default mint if no mints exist. - // NOTE: the mint MUST send permissive CORS headers, otherwise the browser - // blocks every request with a NetworkError ("Failed to fetch mint"). - // mint.minibits.cash does NOT set Access-Control-Allow-Origin, so it can't - // be used directly from a browser — we default to a CORS-enabled mint. + // Add a default mint if the user has none yet. + // + // A mint MUST send permissive CORS headers or the browser blocks every + // request with a NetworkError ("Failed to fetch mint"). We can't know from + // build time which mint is reachable in THIS browser, so we probe an + // ordered list of web-friendly candidates and add the first that works. + // (mint.minibits.cash is intentionally excluded — it's mobile-app only.) try { - const DEFAULT_MINT_URL = 'https://mint.coinos.io'; const existingMints = await coco.mint.getAllTrustedMints(); - const hasDefaultMint = existingMints.some((m: Mint) => - m.mintUrl.toLowerCase() === DEFAULT_MINT_URL.toLowerCase() - ); - - if (!hasDefaultMint && existingMints.length === 0) { - console.log('[Cashu] Adding default mint:', DEFAULT_MINT_URL); - await coco.mint.addMint(DEFAULT_MINT_URL, { trusted: true }); - console.log('[Cashu] Default mint added successfully'); + if (existingMints.length === 0) { + const CANDIDATE_MINTS = [ + 'https://mint.coinos.io', + 'https://21mint.me', + 'https://stablenut.cashu.network', + 'https://mint.lnvoltz.com', + ]; + for (const url of CANDIDATE_MINTS) { + const probe = await probeMintReachability(url); + if (probe.reachability === 'ok') { + console.log('[Cashu] Adding default mint:', url); + await coco.mint.addMint(url, { trusted: true }); + console.log('[Cashu] Default mint added successfully'); + break; + } + console.warn('[Cashu] Skipping unreachable default candidate:', url, probe.reachability); + } } } catch (err) { console.warn('[Cashu] Failed to add default mint:', err); diff --git a/src/hooks/wallet/useMintManager.ts b/src/hooks/wallet/useMintManager.ts index 57331a6..5e0c788 100644 --- a/src/hooks/wallet/useMintManager.ts +++ b/src/hooks/wallet/useMintManager.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import type { Manager, Repositories } from 'coco-cashu-core'; import { useToast } from '@/hooks/useToast'; +import { probeMintReachability } from '@/lib/mintReachability'; export interface CashuMint { url: string; @@ -84,6 +85,20 @@ export function useMintManager({ throw new Error('This mint is already in your list'); } + // Probe reachability from the browser first — a CORS-blocked mint would + // otherwise be added but fail on every invoice with a cryptic error. + const probe = await probeMintReachability(normalizedUrl); + if (probe.reachability === 'cors-blocked') { + throw new Error( + `This mint can't be used from a web browser — it doesn't allow web requests (no CORS). It may work in a mobile app, but pick a web-friendly mint here.`, + ); + } + if (probe.reachability === 'unreachable') { + throw new Error( + `Couldn't reach this mint. Check the URL and that the mint is online, then try again.`, + ); + } + // Add mint and automatically trust it await coco.mint.addMint(normalizedUrl, { trusted: true }); diff --git a/src/lib/mintReachability.ts b/src/lib/mintReachability.ts new file mode 100644 index 0000000..b984fd3 --- /dev/null +++ b/src/lib/mintReachability.ts @@ -0,0 +1,75 @@ +/** + * Browser-side Cashu mint reachability check. + * + * A Cashu mint is only usable from a web wallet if it answers `GET /v1/info` + * with permissive CORS headers. Many popular mints (e.g. mint.minibits.cash) + * work great in mobile apps but do NOT set `Access-Control-Allow-Origin`, so the + * browser blocks them with a `NetworkError` — which otherwise only surfaces + * later as a cryptic "Failed to create invoice". + * + * This probe runs in the USER's browser (where CORS actually applies), so it's + * the only reliable place to detect the problem. + */ +export type MintReachability = 'ok' | 'cors-blocked' | 'unreachable'; + +export interface MintProbeResult { + reachability: MintReachability; + /** Mint name from /v1/info when reachable. */ + name?: string; +} + +function normalizeMintUrl(url: string): string { + let u = url.trim().replace(/\/+$/, ''); + if (!/^https?:\/\//.test(u)) u = `https://${u}`; + return u; +} + +/** + * Probe a mint's `/v1/info` from the browser. Distinguishes a CORS block + * (fetch rejects with a TypeError and the request never yields a response) + * from a genuinely down mint. Best-effort — treats ambiguous failures as + * `unreachable` rather than falsely flagging CORS. + */ +export async function probeMintReachability( + mintUrl: string, + timeoutMs = 7000, +): Promise { + const base = normalizeMintUrl(mintUrl); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(`${base}/v1/info`, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal, + mode: 'cors', + }); + + if (!res.ok) { + return { reachability: 'unreachable' }; + } + + let name: string | undefined; + try { + const info = await res.json(); + if (info && typeof info.name === 'string') name = info.name; + } catch { + // info body wasn't JSON — still counts as reachable/CORS-ok + } + return { reachability: 'ok', name }; + } catch (err) { + // A CORS failure throws a TypeError with a "Failed to fetch"-style message + // and no response object. An abort (timeout) is a genuine unreachable. + if (controller.signal.aborted) { + return { reachability: 'unreachable' }; + } + const msg = err instanceof Error ? err.message : String(err); + if (/failed to fetch|networkerror|load failed|cors/i.test(msg)) { + return { reachability: 'cors-blocked' }; + } + return { reachability: 'unreachable' }; + } finally { + clearTimeout(timer); + } +}