import Dexie, { type Table } from "dexie"; import { finalizeEvent, generateSecretKey, getPublicKey, nip04, nip19, SimplePool, verifyEvent, } from "nostr-tools"; import type { Event as NostrEvent } from "nostr-tools"; import { derived, get, type Writable, writable } from "svelte/store"; import { browser } from "$app/environment"; import { getBreezSDK, refreshBalances } from "$lib/breez.svelte"; import type { BindingLiquidSdk } from "@breeztech/breez-sdk-liquid/web"; import { bytesToHex, hexToBytes, type MetaEntry, persistentDbWritable, } from "./utils"; import { cashuDB, cashuState, getNpub } from "./cashu.svelte"; import { send } from "./payments"; interface PaidInvoice { paymentHash: string; timestamp: number; } class NwcDB extends Dexie { meta!: Table; paidInvoices!: Table; constructor() { super("nwc"); this.version(2).stores({ meta: "&key", paidInvoices: "&paymentHash", }); } } const nwcDB = new NwcDB(); export const nwcPrivkey = persistentDbWritable( "nwc_privkey", null, nwcDB, ); export const nwcSecret = persistentDbWritable( "nwc_secret", null, nwcDB, ); export const nwcRelays = persistentDbWritable( "nwc_relays", [ "wss://relay.arx-ccn.com", ], nwcDB, ); export const connectUri = derived( [nwcPrivkey, nwcSecret, nwcRelays], ([$nwcPrivkey, $nwcSecret, $nwcRelays]) => { if (!$nwcPrivkey || !$nwcSecret) return ""; const privBytes = hexToBytes($nwcPrivkey); const relayParam = encodeURIComponent($nwcRelays.join(",")); return `nostr+walletconnect://${ getPublicKey(privBytes) }?relay=${relayParam}&secret=${$nwcSecret}`; }, ); interface JsonRpcReq { id: string; method: | "pay_invoice" | "get_balance" | "list_transactions" | "get_info" | "make_invoice" | "lookup_invoice"; params: Record; } interface JsonRpcSuccess { result_type: string; result: T; } interface JsonRpcError { result_type: string; error: { code: | "RATE_LIMITED" | "NOT_IMPLEMENTED" | "INSUFFICIENT_BALANCE" | "QUOTA_EXCEEDED" | "RESTRICTED" | "UNAUTHORIZED" | "INTERNAL" | "NOT_FOUND" | "PAYMENT_FAILED" | "OTHER"; message: string; }; } type JsonRpcResp = JsonRpcSuccess | JsonRpcError; let pool: SimplePool | null = null; let sub: ReturnType | null = null; let active = false; export async function start(): Promise { if (!browser || active) return; ensurePrivkey(); ensureSecret(); await connect(); active = true; } export function stop(): void { if (!browser || !active) return; sub?.close(); pool?.close([]); pool = null; sub = null; active = false; } nwcRelays.subscribe(() => { if (active) { stop(); start(); } }); function ensurePrivkey(): string { const current = get(nwcPrivkey); if (current) return current; const pk = bytesToHex(generateSecretKey()); nwcPrivkey.set(pk); return pk; } function ensureSecret(): string { const current = get(nwcSecret); if (current) return current; const secret = bytesToHex(generateSecretKey()); nwcSecret.set(secret); return secret; } async function connect(): Promise { const relays = get(nwcRelays); const maybePriv = get(nwcPrivkey); if (!maybePriv) throw new Error("NWC privkey missing"); const privkey = maybePriv; const pubkey = getPublicKey(hexToBytes(privkey)); const breezSDK = await getBreezSDK(); pool = new SimplePool(); sub = pool.subscribeMany(relays, [ { kinds: [23194], // NWC request events "#p": [pubkey], }, ], { onevent: async (evt: NostrEvent) => { await handleEvent(evt, privkey, pubkey, relays, breezSDK); }, }); } async function handleEvent( evt: NostrEvent, privkey: string, ourPubkey: string, relays: string[], breezSDK: BindingLiquidSdk, ): Promise { try { if (!verifyEvent(evt)) return; const decrypted = await nip04.decrypt( hexToBytes(privkey), evt.pubkey, evt.content, ); const req = JSON.parse(decrypted) as JsonRpcReq; const result = await processRpc(req); await sendResponse( req.id, req.method, result, evt.pubkey, privkey, ourPubkey, relays, evt.id, ); } catch (err) { console.error("NWC handleEvent error", err); const req = JSON.parse( await nip04.decrypt(hexToBytes(privkey), evt.pubkey, evt.content), ) as JsonRpcReq; const errorResp: JsonRpcError = { result_type: req.method, error: { code: "INTERNAL", message: (err as Error).message, }, }; await sendResponse( req.id, req.method, errorResp, evt.pubkey, privkey, ourPubkey, relays, evt.id, ); } } async function processRpc( req: JsonRpcReq, ): Promise { console.log("processRpc", req); switch (req.method) { case "pay_invoice": { const destination = req.params?.invoice as string | undefined; if (!destination) { throw { code: "INTERNAL", message: "missing_invoice" }; } const breezSDK = await getBreezSDK(); const parsed = await breezSDK.parse(destination); if (parsed.type !== "bolt11") { throw { code: "INTERNAL", message: "not a bolt11 invoice" }; } const paymentHash = (parsed as any).invoice.paymentHash; const existing = await nwcDB.paidInvoices.get(paymentHash); if (existing) { throw { code: "OTHER", message: "invoice already paid" }; } const sender = send(destination, 0, parsed); let res = await sender.next(); while (!res.done) { res = await sender.next(); } if (res.value.error) { throw { code: "PAYMENT_FAILED", message: res.value.error }; } if (res.value.paymentHash) { await nwcDB.paidInvoices.put({ paymentHash: res.value.paymentHash, timestamp: Math.floor(Date.now() / 1000), }); } await refreshBalances(); return { preimage: res.value.preimage, paymentHash: res.value.paymentHash, }; } case "get_balance": { const breezSDK = await getBreezSDK(); const info = await breezSDK.getInfo(); return { balance: info.walletInfo.balanceSat * 1000 + get(cashuState).balance * 1000, }; } case "list_transactions": { const from_timestamp = (req.params?.from_timestamp as number | undefined) ?? 0; const to_timestamp = (req.params?.to_timestamp as number | undefined) ?? undefined; const limit = (req.params?.limit as number | undefined) ?? 20; const offset = (req.params?.offset as number | undefined) ?? undefined; const breezSDK = await getBreezSDK(); const liquidPayments = (await breezSDK.listPayments({ fromTimestamp: from_timestamp, toTimestamp: to_timestamp, limit, offset, })) as any[]; const cashuTxns = await cashuDB.txns.toArray(); const allTxns = [ ...liquidPayments.map((p) => ({ type: p.paymentType, invoice: p.bolt11, description: p.description, payment_hash: p.paymentHash, preimage: p.paymentPreimage, amount: p.amountSat * 1000, fees_paid: p.feesSat * 1000, created_at: Math.floor(p.paymentTime / 1000), settled_at: p.status === "complete" ? Math.floor(p.paymentTime / 1000) : undefined, })), ...cashuTxns.map((p) => ({ type: p.paymentType, invoice: "", description: "Cashu", payment_hash: p.txId, preimage: "", amount: p.amountSat * 1000, fees_paid: 0, created_at: p.timestamp, settled_at: p.timestamp, })), ].sort((a, b) => b.created_at - a.created_at); return { transactions: allTxns, }; } case "get_info": { const breezSDK = await getBreezSDK(); const info = await breezSDK.getInfo(); const privkey = get(nwcPrivkey); if (!privkey) throw new Error("missing_privkey"); return { alias: "Portal BTC Wallet", color: "#ff6b35", pubkey: getPublicKey(hexToBytes(privkey)), network: "mainnet", methods: [ "pay_invoice", "get_balance", "list_transactions", "get_info", "make_invoice", "lookup_invoice", ], notifications: [], }; } case "make_invoice": { const amountMsat = req.params?.amount as number | undefined; if (!amountMsat) { throw { code: "INTERNAL", message: "missing_amount" }; } const npub = await getNpub(); const res = await fetch( `/.well-known/lnurl-pay/callback/${npub}?amount=${amountMsat}`, ); if (!res.ok) { const errorText = await res.text(); console.error("Failed to create invoice:", errorText); throw { code: "INTERNAL", message: `Failed to create invoice: ${errorText}`, }; } const { pr: invoice } = await res.json(); if (!invoice) { throw { code: "INTERNAL", message: "failed_to_create_invoice" }; } return { invoice, }; } case "lookup_invoice": { const paymentHash = req.params?.payment_hash as string | undefined; if (!paymentHash) { throw { code: "INTERNAL", message: "missing_payment_hash" }; } const breezSDK = await getBreezSDK(); const payments = (await breezSDK.listPayments({})) as any[]; const payment = payments.find((p) => p.paymentHash === paymentHash); if (!payment) { throw { code: "NOT_FOUND", message: "invoice_not_found" }; } return { type: payment.paymentType, invoice: payment.bolt11, description: payment.description, payment_hash: payment.paymentHash, preimage: payment.paymentPreimage, amount: payment.amountSat * 1000, fees_paid: payment.feesSat * 1000, created_at: Math.floor(payment.paymentTime / 1000), settled_at: payment.status === "complete" ? Math.floor(payment.paymentTime / 1000) : undefined, }; } default: throw { code: "NOT_IMPLEMENTED", message: "unknown_method" }; } } async function sendResponse( id: string, method: string, payload: JsonRpcResp | unknown, receiverPubkey: string, privkey: string, ourPubkey: string, relays: string[], reqId: string, ): Promise { const resp: JsonRpcResp = (payload as JsonRpcError).error ? (payload as JsonRpcError) : { result_type: method, result: payload }; const content = JSON.stringify({ id, ...resp }); const encrypted = await nip04.encrypt( hexToBytes(privkey), receiverPubkey, content, ); const event: NostrEvent = { kind: 23195, pubkey: ourPubkey, created_at: Math.floor(Date.now() / 1000), tags: [ ["p", receiverPubkey], ["e", reqId], ], content: encrypted, } as NostrEvent; const signedEvent = finalizeEvent( event, hexToBytes(privkey), ); pool?.publish(relays, signedEvent); }