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:
parent
b830bf965d
commit
fc7564a6f8
8 changed files with 123 additions and 35 deletions
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
28
src/lib/platform.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue