Let's get this started
This commit is contained in:
commit
c6ce63b6fa
46 changed files with 3983 additions and 0 deletions
459
src/lib/nwc.svelte.ts
Normal file
459
src/lib/nwc.svelte.ts
Normal file
|
@ -0,0 +1,459 @@
|
|||
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);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue