372 lines
9.3 KiB
TypeScript
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();
|
|
}
|