import Dexie, { type Table } from "dexie"; import { finalizeEvent, generateSecretKey, getPublicKey, nip04, SimplePool, verifyEvent, } from "nostr-tools"; import type { Event as NostrEvent } from "nostr-tools"; import { derived, get } from "svelte/store"; import { browser } from "$app/environment"; import { bytesToHex, hexToBytes, type MetaEntry, persistentDbWritable, } from "$lib"; import { walletState } from "$lib/wallet.svelte"; 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"; 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)); pool = new SimplePool(); sub = pool.subscribeMany(relays, [ { kinds: [23194], // NWC request events "#p": [pubkey], }, ], { onevent: async (evt: NostrEvent) => { await handleEvent(evt, privkey, pubkey, relays); }, }); } async function handleEvent( evt: NostrEvent, privkey: string, ourPubkey: string, relays: string[], ): 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 { const currentWalletState = get(walletState); if (!currentWalletState.open) throw new Error("Wallet not open"); 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" }; } if (await nwcDB.paidInvoices.get(destination)) { throw { code: "OTHER", message: "invoice_already_paid" }; } const sender = currentWalletState.wallet.pay(destination, 0); let res; try { res = await sender.next(); while (!res.done) { res = await sender.next(); } } catch (err) { throw { code: "PAYMENT_FAILED", message: (err as Error).message }; } await nwcDB.paidInvoices.put({ paymentHash: destination, timestamp: Math.floor(Date.now() / 1000), }).catch((err) => { console.error("Failed to save paid invoice", err); }); return {}; // TODO: check if it works without the preimage and paymentHash // return { // preimage: res.value.preimage, // paymentHash: res.value.paymentHash, // }; } case "get_balance": { return { balance: currentWalletState.balance * 1000, }; } case "list_transactions": { const limit = (req.params?.limit as number | undefined) ?? 20; const offset = (req.params?.offset as number | undefined) ?? 0; const transactions = await currentWalletState.wallet.listPayments( limit, offset, ); return { transactions }; } case "get_info": { 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", ], notifications: [], }; } case "make_invoice": { const amountMsat = req.params?.amount as number | undefined; if (!amountMsat) { throw { code: "INTERNAL", message: "missing_amount" }; } const npub = currentWalletState.wallet.npub; if (!npub) throw new Error("Wallet not open"); 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, }; } 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); }