import { CashuMint, CashuWallet, MintQuoteState, type Proof, } from "@cashu/cashu-ts"; import { get, writable } from "svelte/store"; import { getMnemonic } from "$lib/wallet.svelte"; import { privateKeyFromSeedWords as nostrPrivateKeyFromSeedWords } from "nostr-tools/nip06"; import { getBreezSDK } from "$lib/breez.svelte"; import { finalizeEvent, getPublicKey, nip19, nip98 } from "nostr-tools"; import Dexie, { type Table } from "dexie"; import { BASE_DOMAIN } from "$lib/config"; interface MetaEntry { key: string; value: string; } interface CashuTxn { txId: string; paymentType: "receive" | "send"; amountSat: number; timestamp: number; status: "complete"; } class CashuDB extends Dexie { proofs!: Table; meta!: Table; txns!: Table; constructor() { super("cashu"); this.version(1).stores({ proofs: "&secret", meta: "&key", }); this.version(2).stores({ txns: "&txId", }); } } export const cashuDB = new CashuDB(); export type { CashuTxn }; const MINT_URL = "https://mint.minibits.cash/Bitcoin"; interface CashuState { balance: number; meltThreshold: number; lastUpdated: number; } export const cashuState = writable({ balance: 0, meltThreshold: 2000, lastUpdated: 0, }); let proofs: Proof[] = []; async function loadProofs() { proofs = await cashuDB.proofs.toArray(); updateBalance(); } async function persistProofs() { await cashuDB.proofs.clear(); if (proofs.length) await cashuDB.proofs.bulkPut(proofs); } async function fetchWithNip98( { url, method, body }: { url: string; method: string; body?: BodyInit | null; }, ): Promise { const mnemonic = await getMnemonic(); const nostrPrivateKey = nostrPrivateKeyFromSeedWords(mnemonic); const urlWithoutQueryParams = url.split("?")[0]; const npubCashNip98 = await nip98.getToken( urlWithoutQueryParams, method, (event) => finalizeEvent(event, nostrPrivateKey), ); return fetch(url, { method, headers: { "Authorization": `Nostr ${npubCashNip98}`, }, body, }).then((data) => data.json()); } export async function getNpub() { const mnemonic = await getMnemonic(); const nostrPrivateKey = nostrPrivateKeyFromSeedWords(mnemonic); const nostrPubkey = getPublicKey(nostrPrivateKey); return nip19.npubEncode(nostrPubkey); } export async function getCashuAddress() { return `${await getNpub()}@${BASE_DOMAIN}`; } interface NpubCashQuote { createdAt: number; paidAt?: number; expiresAt: number; mintUrl: string; quoteId: string; request: string; amount: number; state: "PAID" | "ISSUED" | "INFLIGHT"; locked: boolean; } interface PaginationMetadata { total: number; limit: number; } async function tryRedeemUnredeemedCashuQuotes() { const lastRedeemedCashuQuoteTimestamp = localStorage.getItem("lastRedeemedCashuQuoteTimestamp") || 0; let quotes: NpubCashQuote[] = []; while (true) { const currentQuotes = await fetchWithNip98< { error: false; data: { quotes: NpubCashQuote[] }; metadata: PaginationMetadata; } | { error: true; message: string; } >({ url: `https://npubx.cash/api/v2/wallet/quotes?since=${lastRedeemedCashuQuoteTimestamp}&limit=50&offset=${quotes.length}`, method: "GET", }); if (currentQuotes.error === false) { quotes.push(...currentQuotes.data.quotes); if (quotes.length >= currentQuotes.metadata.total) { break; } } else { throw new Error(currentQuotes.message); } } quotes = quotes.sort((a, b) => a.createdAt - b.createdAt); for (const quote of quotes) { if (quote.state === "PAID") { const mint = new CashuMint(quote.mintUrl); const wallet = new CashuWallet(mint); const req = await mint.checkMintQuote(quote.quoteId); if (req.state === MintQuoteState.PAID && quote.paidAt) { const newProofs = await wallet.mintProofs(quote.amount, quote.quoteId); proofs.push(...newProofs); await persistProofs(); const amountReceived = newProofs.reduce((sum, p) => sum + p.amount, 0); cashuTxns.update((txs) => { txs.push({ txId: `cashu-quote-${quote.quoteId}`, paymentType: "receive", amountSat: amountReceived, timestamp: quote.paidAt ? Math.floor(quote.paidAt) : Math.floor(Date.now() / 1000), status: "complete", }); return txs; }); persistCashuTxns(); updateBalance(); localStorage.setItem( "lastRedeemedCashuQuoteTimestamp", quote.paidAt.toString(), ); } } } return quotes; } let wallet: CashuWallet | undefined; const walletReady = (async () => { if (wallet) return wallet; await loadProofs(); try { await getMnemonic(); } catch (_) {} const mint = new CashuMint(MINT_URL); wallet = new CashuWallet(mint); await wallet.loadMint(); await tryRedeemUnredeemedCashuQuotes(); try { console.log(`cashu addr: ${await getCashuAddress()}`); } catch (_) { } await persistProofs(); return wallet; })(); let quoteRedeemInterval: ReturnType | undefined; if (quoteRedeemInterval) clearInterval(quoteRedeemInterval); quoteRedeemInterval = setInterval( () => tryRedeemUnredeemedCashuQuotes().then(maybeMelt), 1000 * 5, ); function updateBalance() { const balance = proofs.reduce((sum, p) => sum + p.amount, 0); cashuState.update((s) => ({ ...s, balance, lastUpdated: s.lastUpdated + 1 })); } export async function redeemToken(token: string): Promise { if (!token.trim()) throw new Error("Token is empty"); const wallet = await walletReady; const received = await wallet.receive(token.trim()); proofs.push(...received); const amountReceived = received.reduce((sum, p) => sum + p.amount, 0); cashuTxns.update((txs) => { txs.push({ txId: `cashu-receive-${Date.now()}`, paymentType: "receive", amountSat: amountReceived, timestamp: Math.floor(Date.now() / 1000), status: "complete", }); return txs; }); persistCashuTxns(); updateBalance(); await persistProofs(); await maybeMelt(); } type PrepareReceiveRequest = { paymentMethod: "bolt11Invoice"; amountSat: number; }; async function createMeltInvoice(amountSat: number): Promise { const breezSDK = await getBreezSDK(); const prepare = await breezSDK.prepareReceivePayment({ paymentMethod: "bolt11Invoice", amount: { type: "bitcoin", payerAmountSat: amountSat }, amountSat: amountSat, } as PrepareReceiveRequest); const { destination } = await breezSDK.receivePayment({ prepareResponse: prepare, }); return destination as string; } let tryingToMelt = false; async function maybeMelt() { if (tryingToMelt) return; tryingToMelt = true; const { balance, meltThreshold } = get(cashuState); if (balance < meltThreshold) return; try { const wallet = await walletReady; const invoice = await createMeltInvoice(balance - (balance % 2000)); const meltQuote = await wallet.createMeltQuote(invoice); const amountToMelt = meltQuote.amount + meltQuote.fee_reserve; const { keep, send } = await wallet.send(amountToMelt, proofs, { includeFees: true, }); proofs = keep; await persistProofs(); const { change } = await wallet.meltProofs(meltQuote, send); proofs.push(...change); await persistProofs(); cashuTxns.update((txs) => { txs.push({ txId: `cashu-send-${Date.now()}`, paymentType: "send", amountSat: amountToMelt, timestamp: Math.floor(Date.now() / 1000), status: "complete", }); return txs; }); persistCashuTxns(); updateBalance(); } catch (err) { console.error("Failed to melt Cashu balance", err); } tryingToMelt = false; } export const cashuTxns = writable([]); async function loadCashuTxns() { try { const txns = await cashuDB.txns.toArray(); cashuTxns.set(txns); } catch (err) { console.error("Failed to load Cashu txns", err); } } async function persistCashuTxns() { try { const txns = get(cashuTxns); await cashuDB.txns.clear(); if (txns.length) await cashuDB.txns.bulkPut(txns); } catch (err) { console.error("Failed to persist Cashu txns", err); } } loadCashuTxns(); export async function payBolt11Invoice(invoice: string): Promise { if (!invoice.trim()) throw new Error("Invoice is empty"); const wallet = await walletReady; const meltQuote = await wallet.createMeltQuote(invoice); const amountToMelt = meltQuote.amount + meltQuote.fee_reserve; const { balance } = get(cashuState); if (amountToMelt > balance) { throw new Error("Insufficient Cashu balance"); } const { keep, send } = await wallet.send(amountToMelt, proofs, { includeFees: true, }); proofs = keep; await persistProofs(); const { change } = await wallet.meltProofs(meltQuote, send); proofs.push(...change); await persistProofs(); cashuTxns.update((txs) => { txs.push({ txId: `cashu-send-${Date.now()}`, paymentType: "send", amountSat: amountToMelt, timestamp: Math.floor(Date.now() / 1000), status: "complete", }); return txs; }); persistCashuTxns(); updateBalance(); }