// Nostr Wallet Connect (NIP-47) — port of Zeus's backends/NostrWalletConnect.ts. // // A NWC connection is a capability grant, not a node: a `nostr+walletconnect://` URI carries a wallet // service pubkey, one or more relays and a shared secret. Every operation is an encrypted nostr event // round-tripped through a relay, so the whole surface is lightning-only and the wallet decides which // commands it will honour. // // CAPABILITIES ARE DYNAMIC. This is the one backend whose `capabilities` set is not a constant: the // wallet advertises its methods in the `get_info` response (falling back to the kind-13194 info event), // and the set is rebuilt from that list on connect. Zeus hardcodes `supportsKeysend() = false` even // though plenty of NWC wallets do keysend; here `keysend` appears iff the wallet lists `pay_keysend`. // Before the first connect the set holds the NIP-47 baseline (make_invoice + pay_invoice), because // `supports()` is synchronous and cannot await the handshake. // // Differences from Zeus, all deliberate: // // 1. TRANSPORT. Zeus drives `NostrWebLNProvider` from `@getalby/sdk`, a WebLN shim that silently // converts sats↔msats. This uses `NWCClient` from the same package (v8), which speaks raw NIP-47 // — every amount in and out is millisatoshis, matching this contract's msat-as-string rule with // no lossy hop through sats. Bun/Node 22 both provide the global WebSocket that nostr-tools needs. // 2. lookupInvoice. Zeus passes `Base64Utils.hexToBase64(r_hash)` (NostrWalletConnect.ts:78) — NIP-47 // specifies payment_hash as **hex**, so that call cannot match. Hex is sent here. // 3. INVOICE/PAYMENT SPLIT. Zeus calls `list_transactions` and filters `type` client-side, twice. // NIP-47 takes `type` as a request parameter; the filter is pushed to the wallet, with the // client-side filter kept as a guard for wallets that ignore it. // 4. payInvoice. NIP-47 answers a payment with `{preimage, fees_paid}` and nothing else, so Zeus // returns a Payment with no hash. The payment hash is sha256(preimage) by definition, so it is // computed locally; the amount is filled from a best-effort `lookup_invoice` when the wallet // supports it, otherwise from the request. // 5. decodeInvoice is LOCAL. NIP-47 has no decode command, so this is the one operation that never // touches the relay: `../bolt11` (ported from Zeus's Bolt11Utils) decodes in-process. On-chain, // channels, peers and message signing do stay at BaseBackend's 501 — NIP-47 `sign_message` signs // with the node key, which is not what the contract's signMessage means. import { createHash } from 'node:crypto'; import { Nip47Error, Nip47TimeoutError, NWCClient, type Nip47Method, type Nip47Transaction } from '@getalby/sdk/nwc'; import { decodeBolt11 } from '../bolt11'; import { BackendError, type Balances, type BackendKind, type BitcoinNetwork, type Capability, type CreateInvoiceRequest, type DecodedInvoice, type Invoice, type InvoiceState, type KeysendRequest, type NodeInfo, type PayInvoiceRequest, type Payment, type PaymentStatus, } from '../types'; import { BaseBackend } from './base'; export type NwcConfig = { /** A `nostr+walletconnect://?relay=…&secret=…` URI. */ connectionUri: string; }; // ── upstream wire shapes ───────────────────────────────────────────────────────────────────────── /** * The SDK types `state` as required, but it was added late to NIP-47 and pre-1.0 wallets omit it — * their only settlement signal is a non-zero `settled_at`. Amounts are millisatoshis throughout. */ type NwcTransaction = Omit & { state?: Nip47Transaction['state'] }; /** NIP-47 error codes, mapped onto HTTP so routes.ts can answer honestly. */ const ERROR_STATUS: Record = { NOT_IMPLEMENTED: 501, UNSUPPORTED_ENCRYPTION: 501, UNAUTHORIZED: 403, RESTRICTED: 403, INSUFFICIENT_BALANCE: 402, QUOTA_EXCEEDED: 402, PAYMENT_FAILED: 502, NOT_FOUND: 404, RATE_LIMITED: 429, INTERNAL: 502, OTHER: 502, }; /** The keysend TLV that carries a human-readable message (the de-facto standard record). */ const KEYSEND_MESSAGE_TLV = 34349334; // ── helpers ────────────────────────────────────────────────────────────────────────────────────── const now = (): number => Math.floor(Date.now() / 1000); /** payment_hash = sha256(preimage) — how NIP-47 lets us recover a hash it never sends back. */ function hashFromPreimage(preimage: string | undefined): string { if (!preimage) return ''; return createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex'); } /** msat decimal string → the integer msats NIP-47 wants, with the JS-safe range enforced. */ function toMsatNumber(msat: string, field: string): number { const value = BigInt(msat); if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) { throw new BackendError(`${field}=${msat}msat is out of range for NIP-47`, 400, 'BAD_AMOUNT'); } return Number(value); } function toBackendError(err: unknown, op: string): BackendError { if (err instanceof BackendError) return err; if (err instanceof Nip47TimeoutError) { return new BackendError(`nwc ${op} timed out: ${err.message}`, 504, err.code); } if (err instanceof Nip47Error) { return new BackendError(`nwc ${op} failed: ${err.message}`, ERROR_STATUS[err.code] ?? 502, err.code); } return new BackendError(`nwc ${op} failed: ${String(err)}`, 502, 'NWC_ERROR'); } function toInvoiceState(tx: NwcTransaction): InvoiceState { if (tx.state === 'settled') return 'settled'; if (tx.state === 'accepted') return 'accepted'; // NIP-47 has no explicit cancel; a hold invoice that was released comes back as failed. if (tx.state === 'failed') return 'canceled'; if (tx.settled_at) return 'settled'; if (tx.expires_at && tx.expires_at < now()) return 'expired'; return 'open'; } function toPaymentStatus(tx: NwcTransaction): PaymentStatus { if (tx.state === 'settled') return 'succeeded'; if (tx.state === 'failed') return 'failed'; if (tx.state) return 'pending'; return tx.settled_at ? 'succeeded' : 'pending'; } function toInvoice(tx: NwcTransaction): Invoice { const amountMsat = tx.amount != null ? String(tx.amount) : null; const state = toInvoiceState(tx); return { paymentHash: tx.payment_hash ?? '', bolt11: tx.invoice ?? '', amountMsat, amountPaidMsat: state === 'settled' ? amountMsat : null, memo: tx.description || null, state, createdAt: tx.created_at ?? now(), expiresAt: tx.expires_at || null, settledAt: tx.settled_at || null, preimage: tx.preimage || null, // An incoming payment with no BOLT11 attached to it can only have been a keysend. isKeysend: !tx.invoice && !!tx.payment_hash, // NIP-47 has no AMP concept. isAmp: false, }; } function toPayment(tx: NwcTransaction): Payment { return { paymentHash: tx.payment_hash ?? '', preimage: tx.preimage || null, amountMsat: String(tx.amount ?? 0), feeMsat: String(tx.fees_paid ?? 0), status: toPaymentStatus(tx), createdAt: tx.created_at ?? now(), // The wallet never names the payee; only the invoice it paid. destination: null, memo: tx.description || null, failureReason: null, }; } // ── backend ────────────────────────────────────────────────────────────────────────────────────── export class NwcBackend extends BaseBackend { readonly kind: BackendKind = 'nwc'; /** * Mutable behind a ReadonlySet view — `capabilities` and `caps` are the same object, so rebuilding * the set after the get_info handshake is visible through `supports()` without reassigning a * readonly field. Seeded with the NIP-47 baseline until the wallet says otherwise. */ private readonly caps = new Set(['lightningReceive', 'lightningSend']); protected readonly capabilities: ReadonlySet = this.caps; private client: NWCClient | null = null; private connecting: Promise | null = null; private methods: ReadonlySet = new Set(); constructor(private readonly config: NwcConfig) { super(); } // ── connection ───────────────────────────────────────────────────────────────────────────────── /** Single-flight connect: the relay subscription and the get_info handshake happen exactly once. */ private async connect(): Promise { if (this.client) return this.client; this.connecting ??= this.open().finally(() => { this.connecting = null; }); return this.connecting; } private async open(): Promise { let client: NWCClient; try { client = new NWCClient({ nostrWalletConnectUrl: this.config.connectionUri }); } catch (err) { throw new BackendError(`invalid NWC connection URI: ${String(err)}`, 400, 'BAD_CONFIG'); } try { const info = await client.getInfo(); this.applyMethods(info.methods); } catch (err) { // Wallets that do not implement get_info still publish a kind-13194 info event listing their // capabilities. If neither is reachable the connection itself is broken — surface that. try { const service = await client.getWalletServiceInfo(); this.applyMethods(service.capabilities.filter((cap): cap is Nip47Method => cap !== 'notifications')); } catch { client.close(); throw toBackendError(err, 'connect'); } } this.client = client; return client; } /** Capability negotiation: the whole point of NWC's get_info. */ private applyMethods(methods: Nip47Method[] | undefined): void { if (!methods?.length) return; this.methods = new Set(methods); this.caps.clear(); if (this.methods.has('make_invoice')) this.caps.add('lightningReceive'); if (this.methods.has('pay_invoice')) this.caps.add('lightningSend'); if (this.methods.has('pay_keysend')) this.caps.add('keysend'); } private async run(op: string, fn: (client: NWCClient) => Promise): Promise { const client = await this.connect(); try { return await fn(client); } catch (err) { throw toBackendError(err, op); } } /** Drop the relay subscription. Not part of WalletBackend; the factory calls it on teardown. */ close(): void { this.client?.close(); this.client = null; this.methods = new Set(); } // ── node / balances ──────────────────────────────────────────────────────────────────────────── override async getInfo(): Promise { const info = await this.run('get_info', (client) => client.getInfo()); const network = info.network === 'mainnet' || !info.network ? 'bitcoin' : info.network; return { kind: this.kind, // The pubkey belongs to the wallet service's node, not to this connection. pubkey: info.pubkey || null, alias: info.alias || null, // NIP-47 carries no implementation version. version: null, network: (['bitcoin', 'testnet', 'signet', 'regtest'] as const).includes(network as BitcoinNetwork) ? (network as BitcoinNetwork) : 'bitcoin', blockHeight: info.block_height ?? null, // A wallet that answers at all is by definition usable; there is no sync flag in NIP-47. synced: true, }; } override async getBalances(): Promise { const res = await this.run('get_balance', (client) => client.getBalance()); return { // NWC is lightning-only; there is no on-chain side to report. onchainConfirmed: 0, onchainFrozen: 0, onchainUnconfirmed: 0, // `balance` is msats (the WebLN shim Zeus uses divides by 1000 before the UI ever sees it). lightningBalance: Math.floor((res.balance ?? 0) / 1000), lightningInbound: null, }; } // ── lightning ────────────────────────────────────────────────────────────────────────────────── override async getInvoices(opts?: { limit?: number }): Promise { const res = await this.run('list_transactions', (client) => client.listTransactions({ type: 'incoming', limit: opts?.limit, unpaid: true }), ); // The `type` filter is a request parameter, but older wallets ignore it — filter again. return (res.transactions ?? []).filter((tx) => tx.type !== 'outgoing').map(toInvoice); } override async createInvoice(req: CreateInvoiceRequest): Promise { if (!this.caps.has('lightningReceive')) return this.notSupported('lightning receive'); // No NIP-47 wallet accepts a caller-supplied preimage or an AMP invoice, and `private` has no // equivalent — the wallet picks its own route hints. if (req.preimage) return this.notSupported('custom preimages'); if (req.isAmp) return this.notSupported('AMP invoices'); if (!req.amountMsat || req.amountMsat === '0') { throw new BackendError('nwc requires an invoice amount', 400, 'AMOUNT_REQUIRED'); } const amount = toMsatNumber(req.amountMsat, 'amountMsat'); const tx = await this.run('make_invoice', (client) => client.makeInvoice({ amount, description: req.memo, expiry: req.expirySeconds }), ); return toInvoice(tx); } override async lookupInvoice(paymentHash: string): Promise { if (!this.methods.size || this.methods.has('lookup_invoice')) { const tx = await this.lookup({ payment_hash: paymentHash }); return tx ? toInvoice(tx) : null; } return this.notSupported('invoice lookup'); } /** * Purely local — no relay round-trip, and it works while the wallet is unreachable. The invoice is * self-describing, so there is nothing the wallet service could add beyond what the bytes already say. */ override async decodeInvoice(bolt11: string): Promise { return decodeBolt11(bolt11); } override async getPayments(opts?: { limit?: number }): Promise { const res = await this.run('list_transactions', (client) => client.listTransactions({ type: 'outgoing', limit: opts?.limit, unpaid_outgoing: true }), ); return (res.transactions ?? []).filter((tx) => tx.type !== 'incoming').map(toPayment); } override async payInvoice(req: PayInvoiceRequest): Promise { if (!this.caps.has('lightningSend')) return this.notSupported('lightning send'); // Fee limits and timeouts are the wallet's own budget policy; NIP-47 has no field for either. const amount = req.amountMsat ? toMsatNumber(req.amountMsat, 'amountMsat') : undefined; const res = await this.run('pay_invoice', (client) => client.payInvoice({ invoice: req.bolt11, amount })); const paymentHash = hashFromPreimage(res.preimage); // pay_invoice returns only {preimage, fees_paid}. Recover the rest from the wallet's own record // when it keeps one — best effort, never fatal, since the payment has already settled. const record = paymentHash ? await this.lookupQuietly({ payment_hash: paymentHash }) : null; return { paymentHash, preimage: res.preimage || null, amountMsat: record?.amount != null ? String(record.amount) : (req.amountMsat ?? '0'), feeMsat: String(res.fees_paid ?? record?.fees_paid ?? 0), // A NIP-47 pay_invoice that resolves has succeeded; a failure arrives as a Nip47WalletError. status: 'succeeded', createdAt: record?.created_at ?? now(), destination: null, memo: record?.description || null, failureReason: null, }; } override async sendKeysend(req: KeysendRequest): Promise { if (!this.caps.has('keysend')) return this.notSupported('keysend'); const amount = toMsatNumber(req.amountMsat, 'amountMsat'); // NIP-47 carries TLV values as hex. const tlvRecords = req.message ? [{ type: KEYSEND_MESSAGE_TLV, value: Buffer.from(req.message, 'utf8').toString('hex') }] : undefined; const res = await this.run('pay_keysend', (client) => client.payKeysend({ pubkey: req.destination, amount, tlv_records: tlvRecords }), ); return { paymentHash: hashFromPreimage(res.preimage), preimage: res.preimage || null, amountMsat: req.amountMsat, feeMsat: String(res.fees_paid ?? 0), status: 'succeeded', createdAt: now(), destination: req.destination, memo: req.message ?? null, failureReason: null, }; } // ── shared ───────────────────────────────────────────────────────────────────────────────────── private async lookup(request: { payment_hash?: string; invoice?: string }): Promise { try { return await this.run('lookup_invoice', (client) => client.lookupInvoice(request)); } catch (err) { // A wallet that has never seen the hash answers NOT_FOUND; that is an absence, not a failure. if (err instanceof BackendError && err.status === 404) return null; throw err; } } private async lookupQuietly(request: { payment_hash?: string; invoice?: string }): Promise { if (this.methods.size && !this.methods.has('lookup_invoice')) return null; try { return await this.lookup(request); } catch { return null; } } }