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:
parent
f5483a0dbc
commit
71d3d60d09
3 changed files with 115 additions and 14 deletions
|
|
@ -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<ReturnType<typeof initializeCoco>>;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
75
src/lib/mintReachability.ts
Normal file
75
src/lib/mintReachability.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue