- 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>
255 lines
8.4 KiB
TypeScript
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,
|
|
};
|
|
}
|