Let's get this started
This commit is contained in:
commit
c6ce63b6fa
46 changed files with 3983 additions and 0 deletions
372
src/lib/cashu.svelte.ts
Normal file
372
src/lib/cashu.svelte.ts
Normal file
|
@ -0,0 +1,372 @@
|
|||
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();
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue