PortalBTC/src/lib/nwc.svelte.ts

375 lines
8.5 KiB
TypeScript

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<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";
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));
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<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> {
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<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);
}