SkateSpots/src/hooks/wallet/useCashu.ts
shakespeare.diy 0078f75f94 Fix wallet invoice failure (CORS-blocked mint) + history crash
- Root cause: mint.minibits.cash sends no Access-Control-Allow-Origin header, so browsers block it (NetworkError → "Failed to create invoice / Failed to fetch mint"). Default to CORS-friendly mint.coinos.io instead.
- useLightningOperations: detect CORS/network failures on receive & send, show actionable "Can't reach this mint — switch mints" message instead of a dead end.
- MintPrivacyManager: reorder recommended mints by browser reachability; flag Minibits as mobile-app-only (not web-reachable).
- useHistoryManager: fix `mintQuoteRepository.getAll is not a function` crash by probing for whichever list method the installed coco-cashu version exposes (getAll/getAllQuotes/list/...), failing gracefully.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 16:53:40 +07:00

255 lines
8.4 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import { useLocalStorage } from '../useLocalStorage';
import * as bip39 from '@scure/bip39';
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';
// Types from coco-cashu-core
type CocoManager = Awaited<ReturnType<typeof initializeCoco>>;
// Singleton pattern to prevent multiple initializations
let globalCocoInstance: CocoManager | null = null;
let globalRepositories: Repositories | null = null;
let globalInitializationPromise: Promise<{ coco: CocoManager; repositories: Repositories }> | null = null;
export interface UseCashuReturn {
// The typed coco manager (direct library access)
coco: CocoManager | null;
// Repositories for direct access to proof data
repositories: Repositories | null;
// React state (for reactivity)
balances: { [mintUrl: string]: number };
mints: Array<{ url: string; name?: string; info?: unknown }>;
totalBalance: number;
// Status
isInitialized: boolean;
isLoading: boolean;
error: Error | null;
// Mnemonic management
mnemonic: string | null;
setMnemonic: (mnemonic: string) => void;
generateMnemonic: () => string;
// Utility functions
clearError: () => void;
getEncodedToken: typeof getEncodedToken;
refreshMints: () => Promise<void>;
}
export function useCashu(): UseCashuReturn {
const [isInitialized, setIsInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Mnemonic management with localStorage
const [mnemonic, setMnemonicState] = useLocalStorage<string | null>('cashu-mnemonic', null);
// Manager instance ref
const cocoRef = useRef<CocoManager | null>(null);
const repositoriesRef = useRef<Repositories | null>(null);
// React state for UI reactivity
const [balances, setBalances] = useState<{ [mintUrl: string]: number }>({});
const [mints, setMints] = useState<Array<{ url: string; name?: string; info?: unknown }>>([]);
const clearError = useCallback(() => {
setError(null);
}, []);
const generateMnemonic = useCallback(() => {
return bip39.generateMnemonic(wordlist);
}, []);
const refreshMints = useCallback(async () => {
const coco = cocoRef.current;
if (!coco) return;
try {
// Only show trusted mints in the UI
const mintList = await coco.mint.getAllTrustedMints();
setMints(mintList.map((m: Mint) => ({
url: m.mintUrl,
name: m.name,
info: m.mintInfo
})));
} catch (err) {
console.error('Failed to refresh mints:', err);
}
}, []);
const setMnemonic = useCallback((newMnemonic: string) => {
if (!bip39.validateMnemonic(newMnemonic, wordlist)) {
throw new Error('Invalid mnemonic');
}
setMnemonicState(newMnemonic);
// Reinitialize manager with new mnemonic
setIsInitialized(false);
}, [setMnemonicState]);
// Initialize the coco manager
const initializeCocoManager = useCallback(async () => {
if (isInitialized || isLoading) return;
setIsLoading(true);
setError(null);
try {
// Use singleton pattern to prevent multiple initializations
if (globalCocoInstance && globalRepositories) {
cocoRef.current = globalCocoInstance;
repositoriesRef.current = globalRepositories;
setIsInitialized(true);
setIsLoading(false);
return;
}
// If there's already an initialization in progress, wait for it
if (globalInitializationPromise) {
console.log('[Cashu] Waiting for existing initialization...');
const { coco, repositories } = await globalInitializationPromise;
cocoRef.current = coco;
repositoriesRef.current = repositories;
setIsInitialized(true);
setIsLoading(false);
return;
}
// Generate or retrieve mnemonic
let currentMnemonic = mnemonic;
if (!currentMnemonic) {
currentMnemonic = bip39.generateMnemonic(wordlist);
setMnemonicState(currentMnemonic);
}
// Create seed from mnemonic
const seed = bip39.mnemonicToSeedSync(currentMnemonic);
// Initialize repositories with proper initialization
const repo = new IndexedDbRepositories({ name: "cashu-wallet" });
await repo.init();
// Create initialization promise
globalInitializationPromise = (async () => {
const coco = await initializeCoco({
repo,
seedGetter: async () => seed,
logger: new ConsoleLogger("coco", { level: "info" })
});
return { coco, repositories: repo };
})();
// Initialize coco manager
const { coco, repositories } = await globalInitializationPromise;
// Store as singleton
console.log('[Cashu] Storing new coco instance as singleton');
globalCocoInstance = coco;
globalRepositories = repositories;
globalInitializationPromise = null;
cocoRef.current = coco;
repositoriesRef.current = repositories;
// Enable built-in services for automatic quote processing
try {
console.log('[Cashu] Enabling coco services...');
await coco.enableMintQuoteWatcher({ watchExistingPendingOnStart: false });
await coco.enableMintQuoteProcessor();
await coco.enableProofStateWatcher();
console.log('[Cashu] All coco services enabled successfully');
} catch (err) {
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.
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');
}
} catch (err) {
console.warn('[Cashu] Failed to add default mint:', err);
}
setIsInitialized(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Cashu wallet';
setError(new Error(errorMessage));
globalInitializationPromise = null;
} finally {
setIsLoading(false);
}
}, [isInitialized, isLoading, mnemonic, setMnemonicState]);
// Initialize on mount and when mnemonic changes
useEffect(() => {
initializeCocoManager();
}, [initializeCocoManager]);
// Auto-refresh balances and mints on manager events
useEffect(() => {
const coco = cocoRef.current;
if (!coco) return;
const refreshBalances = async () => {
try {
const newBalances = await coco.wallet.getBalances();
setBalances(newBalances);
} catch (err) {
console.error('[Cashu] Failed to refresh balances:', err);
}
};
// Initial load
refreshBalances();
refreshMints();
// Listen to events
const unsubs = [
coco.on('proofs:saved', refreshBalances),
coco.on('proofs:state-changed', refreshBalances),
coco.on('send:created', refreshBalances),
coco.on('receive:created', refreshBalances),
coco.on('mint:added', refreshMints),
coco.on('mint:updated', refreshMints),
];
return () => unsubs.forEach(unsub => unsub());
}, [isInitialized, refreshMints]);
// Calculate total balance
const totalBalance = Object.values(balances).reduce((sum, balance) => sum + balance, 0);
return {
coco: cocoRef.current,
repositories: repositoriesRef.current,
balances,
mints,
totalBalance,
isInitialized,
isLoading,
error,
mnemonic,
setMnemonic,
generateMnemonic,
clearError,
getEncodedToken,
refreshMints,
};
}