Platform-aware wallet mints (Option C: native + browser scoping)

- Add @capacitor/core + src/lib/platform.ts (isNativePlatform/getPlatform, web-safe)
- mintReachability: on native (no CORS jail) short-circuit probe to 'ok' so ANY mint works, including mint.minibits.cash
- useCashu default mint: native prefers minibits (matches skatespots@minibits.cash brand identity) then coinos/21mint; browser uses CORS-friendly candidates
- MintPrivacyManager recommended list is platform-aware: native leads with Minibits (recommended), web leads with Coinos and flags Minibits as native-only
- CashuWallet: 'This mint can't be reached from your browser' banner for the browser CORS case (never shows on native), pointing users to a web-friendly mint or the native app
- Lightning invoice error mentions the native-app option

Same codebase now uses minibits directly when running as the native app, and a CORS-safe mint when running in a web browser.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 17:22:41 +07:00
parent b830bf965d
commit fc7564a6f8
8 changed files with 123 additions and 35 deletions

7
package-lock.json generated
View file

@ -10,6 +10,7 @@
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/camera": "^8.2.1",
"@capacitor/core": "^8.4.2",
"@cashu/cashu-ts": "2.8.1",
"@contextvm/sdk": "^0.12.0",
"@fontsource-variable/inter": "^5.2.6",
@ -292,6 +293,12 @@
"integrity": "sha512-yq4TbVcv9Z91JLr7C5407s3rrXr/Dmk+Pr9FAVRn65s3Jg4iYabjwG4fVKUYlQL5TiUdBbZA3LiNAbUxJNcUkw==",
"license": "MIT"
},
"node_modules/@capacitor/core": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.4.2.tgz",
"integrity": "sha512-fQPRb3JXRaU2pnufDUvqOjhsrYxDQeFRZyaj/sHydIUxD2NxOGHKMpmKUdI+U4OOmKuGZyvRpi7TSJU+7Bjbmg==",
"license": "MIT"
},
"node_modules/@cashu/cashu-ts": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-2.8.1.tgz",

View file

@ -18,6 +18,7 @@
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/camera": "^8.2.1",
"@capacitor/core": "^8.4.2",
"@cashu/cashu-ts": "2.8.1",
"@contextvm/sdk": "^0.12.0",
"@fontsource-variable/inter": "^5.2.6",

View file

@ -98,26 +98,35 @@ export function CashuWallet() {
mints,
});
// Pick an active mint when none is selected — prefer a browser-REACHABLE mint
// so a user whose first saved mint is CORS-blocked (e.g. legacy minibits)
// doesn't get stuck with a mint that fails every invoice.
// Tracks the browser-only case where the user has mints but none are
// reachable (all CORS-blocked). Native shells never hit this.
const [noReachableMint, setNoReachableMint] = useState(false);
// Pick an active mint when none is selected — prefer a REACHABLE mint so a
// user whose first saved mint is CORS-blocked (e.g. legacy minibits) doesn't
// get stuck with a mint that fails every invoice.
useEffect(() => {
if (mints.length === 0 || activeMintUrl) return;
let cancelled = false;
(async () => {
const { probeMintReachability } = await import('@/lib/mintReachability');
// Probe mints in order; select the first reachable one.
for (const m of mints) {
const { probeMintReachability } = await import('@/lib/mintReachability');
const probe = await probeMintReachability(m.url);
if (cancelled) return;
if (probe.reachability === 'ok') {
setActiveMintUrl(m.url);
setNoReachableMint(false);
return;
}
}
// Fallback: nothing reachable — select the first so the UI still renders.
if (!cancelled) setActiveMintUrl(mints[0].url);
// Nothing reachable — select the first so the UI still renders, and flag
// the state so we can warn the user (browser CORS case).
if (!cancelled) {
setActiveMintUrl(mints[0].url);
setNoReachableMint(true);
}
})();
return () => {
@ -190,6 +199,21 @@ export function CashuWallet() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* No reachable mint (browser CORS) warning */}
{noReachableMint && (
<Alert className="bg-amber-950/30 border-amber-500/50">
<AlertCircle className="h-4 w-4 text-amber-400" />
<AlertDescription className="text-gray-300 space-y-1">
<p className="font-bold text-amber-300">This mint can't be reached from your browser</p>
<p className="text-sm">
Your saved mint doesn't allow web requests (no CORS). Add a web-friendly
mint below (e.g. <span className="font-mono">mint.coinos.io</span>) and set it active — or open
SkateSpots in the native app, where any mint (including Minibits) works.
</p>
</AlertDescription>
</Alert>
)}
{/* Balance Display */}
<div className="text-center py-6 space-y-2">
<div className="text-sm text-gray-400 uppercase tracking-wide">Total Balance</div>

View file

@ -25,6 +25,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
import { isNativePlatform } from '@/lib/platform';
interface Mint {
url: string;
@ -45,24 +46,33 @@ interface MintPrivacyManagerProps {
// Recommended mints (no stablecoin mints).
// Ordered by browser reachability — web wallets can only use mints that send
// CORS headers. Minibits works great in mobile apps but does NOT allow browser
// requests, so it's flagged and listed last.
const RECOMMENDED_MINTS = [
{
url: 'https://mint.coinos.io',
name: 'Coinos',
description: 'Web-friendly mint (recommended for browsers)',
},
{
url: 'https://legend.lnbits.com/cashu/api/v1/AptDNABNBXv8gpuywhx6NV',
name: 'LNbits Legend',
description: 'Community mint on LNbits',
},
{
url: 'https://mint.minibits.cash/Bitcoin',
name: 'Minibits',
description: '⚠ Mobile-app only — not reachable from web browsers',
},
// CORS headers. Minibits works great natively but does NOT allow browser
// requests, so on the web it's flagged and listed last. In a native shell there
// is no CORS restriction, so Minibits (the SkateSpots brand mint) leads.
const MINIBITS = {
url: 'https://mint.minibits.cash/Bitcoin',
name: 'Minibits',
};
const COINOS = {
url: 'https://mint.coinos.io',
name: 'Coinos',
};
const LNBITS = {
url: 'https://legend.lnbits.com/cashu/api/v1/AptDNABNBXv8gpuywhx6NV',
name: 'LNbits Legend',
description: 'Community mint on LNbits',
};
const RECOMMENDED_MINTS_WEB = [
{ ...COINOS, description: 'Web-friendly mint (recommended for browsers)' },
LNBITS,
{ ...MINIBITS, description: '⚠ Native app only — not reachable from web browsers' },
];
const RECOMMENDED_MINTS_NATIVE = [
{ ...MINIBITS, description: 'SkateSpots mint — recommended' },
{ ...COINOS, description: 'Web-friendly mint' },
LNBITS,
];
export function MintPrivacyManager({
@ -75,6 +85,7 @@ export function MintPrivacyManager({
totalBalance,
className,
}: MintPrivacyManagerProps) {
const RECOMMENDED_MINTS = isNativePlatform() ? RECOMMENDED_MINTS_NATIVE : RECOMMENDED_MINTS_WEB;
const [showPrivacyTips, setShowPrivacyTips] = useState(false);
const [showAddMint, setShowAddMint] = useState(false);
const [mintUrl, setMintUrl] = useState('');

View file

@ -6,6 +6,7 @@ import { initializeCoco, ConsoleLogger, getEncodedToken, type Repositories } fro
import { IndexedDbRepositories } from 'coco-cashu-indexeddb';
import type { Mint } from 'coco-cashu-core';
import { probeMintReachability } from '@/lib/mintReachability';
import { isNativePlatform } from '@/lib/platform';
// Types from coco-cashu-core
type CocoManager = Awaited<ReturnType<typeof initializeCoco>>;
@ -193,12 +194,21 @@ export function useCashu(): UseCashuReturn {
}
if (!hasReachable) {
const CANDIDATE_MINTS = [
'https://mint.coinos.io',
'https://21mint.me',
'https://stablenut.cashu.network',
'https://mint.lnvoltz.com',
];
// Native shells have no CORS jail, so prefer the SkateSpots brand mint
// (mint.minibits.cash — matches skatespots@minibits.cash). In a browser
// that mint is blocked, so fall back to CORS-friendly candidates.
const CANDIDATE_MINTS = isNativePlatform()
? [
'https://mint.minibits.cash/Bitcoin',
'https://mint.coinos.io',
'https://21mint.me',
]
: [
'https://mint.coinos.io',
'https://21mint.me',
'https://stablenut.cashu.network',
'https://mint.lnvoltz.com',
];
const alreadyHave = new Set(
existingMints.map((m: Mint) => m.mintUrl.toLowerCase()),
);

View file

@ -100,7 +100,7 @@ export function useLightningOperations({
variant: 'destructive',
title: looksUnreachable ? "Can't reach this mint" : 'Failed to create invoice',
description: looksUnreachable
? `${activeMintUrl?.replace(/^https?:\/\//, '')} can't be reached from your browser (it may not allow web requests). Switch to a different mint in wallet settings and try again.`
? `${activeMintUrl?.replace(/^https?:\/\//, '')} can't be reached from your browser (no CORS). Switch to a web-friendly mint in wallet settings — or use the native app, where any mint works.`
: raw,
});
}

View file

@ -1,15 +1,17 @@
/**
* Browser-side Cashu mint reachability check.
* Cashu mint reachability check.
*
* A Cashu mint is only usable from a web wallet if it answers `GET /v1/info`
* 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.
* In a NATIVE (Capacitor) shell there is no CORS restriction, so every mint is
* reachable and we short-circuit the probe to `ok`.
*/
import { isNativePlatform } from '@/lib/platform';
export type MintReachability = 'ok' | 'cors-blocked' | 'unreachable';
export interface MintProbeResult {
@ -34,6 +36,11 @@ export async function probeMintReachability(
mintUrl: string,
timeoutMs = 7000,
): Promise<MintProbeResult> {
// Native shells (Capacitor WebView) aren't subject to CORS — any mint works.
if (isNativePlatform()) {
return { reachability: 'ok' };
}
const base = normalizeMintUrl(mintUrl);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);

28
src/lib/platform.ts Normal file
View file

@ -0,0 +1,28 @@
import { Capacitor } from '@capacitor/core';
/**
* Is the app running inside a native (Capacitor) shell — iOS / Android / desktop
* WebView — rather than a plain web browser?
*
* This matters for networking: a native WebView is NOT subject to browser CORS
* rules, so it can talk to any Cashu mint directly (including mint.minibits.cash,
* which sends no CORS headers). In a plain browser we must use a CORS-enabled
* mint. `Capacitor.isNativePlatform()` is web-safe and simply returns `false`
* when there's no native runtime.
*/
export function isNativePlatform(): boolean {
try {
return Capacitor.isNativePlatform();
} catch {
return false;
}
}
/** 'ios' | 'android' | 'web' */
export function getPlatform(): string {
try {
return Capacitor.getPlatform();
} catch {
return 'web';
}
}