459 lines
11 KiB
TypeScript
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);
|
|
}
|