PortalBTC/src/lib/cashu.svelte.ts

372 lines
9.3 KiB
TypeScript

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<Proof, string>;
meta!: Table<MetaEntry, string>;
txns!: Table<CashuTxn, string>;
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<CashuState>({
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<T>(
{ url, method, body }: {
url: string;
method: string;
body?: BodyInit | null;
},
): Promise<T> {
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<typeof setInterval> | 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<void> {
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<string> {
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<CashuTxn[]>([]);
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<void> {
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();
}