PortalBTC/src/lib/nwc.svelte.ts

459 lines
11 KiB
TypeScript

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<MetaEntry, string>;
paidInvoices!: Table<PaidInvoice, string>;
constructor() {
super("nwc");
this.version(2).stores({
meta: "&key",
paidInvoices: "&paymentHash",
});
}
}
const nwcDB = new NwcDB();
export const nwcPrivkey = persistentDbWritable<string | null, NwcDB>(
"nwc_privkey",
null,
nwcDB,
);
export const nwcSecret = persistentDbWritable<string | null, NwcDB>(
"nwc_secret",
null,
nwcDB,
);
export const nwcRelays = persistentDbWritable<string[], NwcDB>(
"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<string, unknown>;
}
interface JsonRpcSuccess<T = unknown> {
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<SimplePool["subscribeMany"]> | null = null;
let active = false;
export async function start(): Promise<void> {
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<void> {
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<void> {
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<unknown> {
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<void> {
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);
}