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