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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 17:04:43 +07:00
parent f5483a0dbc
commit 71d3d60d09
3 changed files with 115 additions and 14 deletions

View file

@ -5,6 +5,7 @@ import { wordlist } from '@scure/bip39/wordlists/english';
import { initializeCoco, ConsoleLogger, getEncodedToken, type Repositories } from 'coco-cashu-core'; import { initializeCoco, ConsoleLogger, getEncodedToken, type Repositories } from 'coco-cashu-core';
import { IndexedDbRepositories } from 'coco-cashu-indexeddb'; import { IndexedDbRepositories } from 'coco-cashu-indexeddb';
import type { Mint } from 'coco-cashu-core'; import type { Mint } from 'coco-cashu-core';
import { probeMintReachability } from '@/lib/mintReachability';
// Types from coco-cashu-core // Types from coco-cashu-core
type CocoManager = Awaited<ReturnType<typeof initializeCoco>>; type CocoManager = Awaited<ReturnType<typeof initializeCoco>>;
@ -166,22 +167,32 @@ export function useCashu(): UseCashuReturn {
console.warn('[Cashu] Failed to enable some coco services:', err); console.warn('[Cashu] Failed to enable some coco services:', err);
} }
// Add default mint if no mints exist. // Add a default mint if the user has none yet.
// NOTE: the mint MUST send permissive CORS headers, otherwise the browser //
// blocks every request with a NetworkError ("Failed to fetch mint"). // A mint MUST send permissive CORS headers or the browser blocks every
// mint.minibits.cash does NOT set Access-Control-Allow-Origin, so it can't // request with a NetworkError ("Failed to fetch mint"). We can't know from
// be used directly from a browser — we default to a CORS-enabled mint. // 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 { try {
const DEFAULT_MINT_URL = 'https://mint.coinos.io';
const existingMints = await coco.mint.getAllTrustedMints(); const existingMints = await coco.mint.getAllTrustedMints();
const hasDefaultMint = existingMints.some((m: Mint) => if (existingMints.length === 0) {
m.mintUrl.toLowerCase() === DEFAULT_MINT_URL.toLowerCase() const CANDIDATE_MINTS = [
); 'https://mint.coinos.io',
'https://21mint.me',
if (!hasDefaultMint && existingMints.length === 0) { 'https://stablenut.cashu.network',
console.log('[Cashu] Adding default mint:', DEFAULT_MINT_URL); 'https://mint.lnvoltz.com',
await coco.mint.addMint(DEFAULT_MINT_URL, { trusted: true }); ];
console.log('[Cashu] Default mint added successfully'); 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) { } catch (err) {
console.warn('[Cashu] Failed to add default mint:', err); console.warn('[Cashu] Failed to add default mint:', err);

View file

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import type { Manager, Repositories } from 'coco-cashu-core'; import type { Manager, Repositories } from 'coco-cashu-core';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { probeMintReachability } from '@/lib/mintReachability';
export interface CashuMint { export interface CashuMint {
url: string; url: string;
@ -84,6 +85,20 @@ export function useMintManager({
throw new Error('This mint is already in your list'); 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 // Add mint and automatically trust it
await coco.mint.addMint(normalizedUrl, { trusted: true }); await coco.mint.addMint(normalizedUrl, { trusted: true });

View file

@ -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<MintProbeResult> {
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);
}
}