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

View file

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

View file

@ -98,26 +98,35 @@ export function CashuWallet() {
mints, mints,
}); });
// Pick an active mint when none is selected — prefer a browser-REACHABLE mint // Tracks the browser-only case where the user has mints but none are
// so a user whose first saved mint is CORS-blocked (e.g. legacy minibits) // reachable (all CORS-blocked). Native shells never hit this.
// doesn't get stuck with a mint that fails every invoice. 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(() => { useEffect(() => {
if (mints.length === 0 || activeMintUrl) return; if (mints.length === 0 || activeMintUrl) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const { probeMintReachability } = await import('@/lib/mintReachability');
// Probe mints in order; select the first reachable one. // Probe mints in order; select the first reachable one.
for (const m of mints) { for (const m of mints) {
const { probeMintReachability } = await import('@/lib/mintReachability');
const probe = await probeMintReachability(m.url); const probe = await probeMintReachability(m.url);
if (cancelled) return; if (cancelled) return;
if (probe.reachability === 'ok') { if (probe.reachability === 'ok') {
setActiveMintUrl(m.url); setActiveMintUrl(m.url);
setNoReachableMint(false);
return; return;
} }
} }
// Fallback: nothing reachable — select the first so the UI still renders. // Nothing reachable — select the first so the UI still renders, and flag
if (!cancelled) setActiveMintUrl(mints[0].url); // the state so we can warn the user (browser CORS case).
if (!cancelled) {
setActiveMintUrl(mints[0].url);
setNoReachableMint(true);
}
})(); })();
return () => { return () => {
@ -190,6 +199,21 @@ export function CashuWallet() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <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 */} {/* Balance Display */}
<div className="text-center py-6 space-y-2"> <div className="text-center py-6 space-y-2">
<div className="text-sm text-gray-400 uppercase tracking-wide">Total Balance</div> <div className="text-sm text-gray-400 uppercase tracking-wide">Total Balance</div>

View file

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

View file

@ -6,6 +6,7 @@ import { initializeCoco, ConsoleLogger, getEncodedToken, type Repositories } fro
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'; import { probeMintReachability } from '@/lib/mintReachability';
import { isNativePlatform } from '@/lib/platform';
// Types from coco-cashu-core // Types from coco-cashu-core
type CocoManager = Awaited<ReturnType<typeof initializeCoco>>; type CocoManager = Awaited<ReturnType<typeof initializeCoco>>;
@ -193,7 +194,16 @@ export function useCashu(): UseCashuReturn {
} }
if (!hasReachable) { if (!hasReachable) {
const CANDIDATE_MINTS = [ // 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://mint.coinos.io',
'https://21mint.me', 'https://21mint.me',
'https://stablenut.cashu.network', 'https://stablenut.cashu.network',

View file

@ -100,7 +100,7 @@ export function useLightningOperations({
variant: 'destructive', variant: 'destructive',
title: looksUnreachable ? "Can't reach this mint" : 'Failed to create invoice', title: looksUnreachable ? "Can't reach this mint" : 'Failed to create invoice',
description: looksUnreachable 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, : 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) * 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 * 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 * browser blocks them with a `NetworkError` — which otherwise only surfaces
* later as a cryptic "Failed to create invoice". * later as a cryptic "Failed to create invoice".
* *
* This probe runs in the USER's browser (where CORS actually applies), so it's * In a NATIVE (Capacitor) shell there is no CORS restriction, so every mint is
* the only reliable place to detect the problem. * reachable and we short-circuit the probe to `ok`.
*/ */
import { isNativePlatform } from '@/lib/platform';
export type MintReachability = 'ok' | 'cors-blocked' | 'unreachable'; export type MintReachability = 'ok' | 'cors-blocked' | 'unreachable';
export interface MintProbeResult { export interface MintProbeResult {
@ -34,6 +36,11 @@ export async function probeMintReachability(
mintUrl: string, mintUrl: string,
timeoutMs = 7000, timeoutMs = 7000,
): Promise<MintProbeResult> { ): Promise<MintProbeResult> {
// Native shells (Capacitor WebView) aren't subject to CORS — any mint works.
if (isNativePlatform()) {
return { reachability: 'ok' };
}
const base = normalizeMintUrl(mintUrl); const base = normalizeMintUrl(mintUrl);
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); 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';
}
}