375 lines
8.5 KiB
TypeScript
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);
|
|
}
|