four wallet defects, none urgent, all cheap: selectCoins let an explicit coin-control pick override a freeze. naming an outpoint now overrides only the confirmed-only default; frozen is absolute. balances counted frozen coins in onchainConfirmed, so Send showed a figure a max-value spend could not reach. Balances gains onchainFrozen — a component of confirmed, not a deduction — filled at the route layer, because freezing is Officer policy in Postgres and no backend can see it. The route only reads utxos when something is actually frozen. Send subtracts it under "Spendable"; Overview lists it beside unconfirmed. a rescan in flight upstream was invisible after a sidecar restart, and a second POST would have queued behind it (scantxoutset is single-threaded node-wide). adoptRescan polls an existing NBXplorer scan instead of starting one, and the GET route falls back to it when local state is gone. the per-variant scan deadline counted queue time, so a variant that sat behind another wallet's scan timed out without ever having run. the deadline now refreshes while the status reads Queued. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
409 lines
18 KiB
TypeScript
409 lines
18 KiB
TypeScript
// 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://<wallet-pubkey>?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<Nip47Transaction, 'state'> & { state?: Nip47Transaction['state'] };
|
|
|
|
/** NIP-47 error codes, mapped onto HTTP so routes.ts can answer honestly. */
|
|
const ERROR_STATUS: Record<string, number> = {
|
|
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<Capability>(['lightningReceive', 'lightningSend']);
|
|
protected readonly capabilities: ReadonlySet<Capability> = this.caps;
|
|
|
|
private client: NWCClient | null = null;
|
|
private connecting: Promise<NWCClient> | null = null;
|
|
private methods: ReadonlySet<Nip47Method> = new Set<Nip47Method>();
|
|
|
|
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<NWCClient> {
|
|
if (this.client) return this.client;
|
|
this.connecting ??= this.open().finally(() => {
|
|
this.connecting = null;
|
|
});
|
|
return this.connecting;
|
|
}
|
|
|
|
private async open(): Promise<NWCClient> {
|
|
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<T>(op: string, fn: (client: NWCClient) => Promise<T>): Promise<T> {
|
|
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<Nip47Method>();
|
|
}
|
|
|
|
// ── node / balances ────────────────────────────────────────────────────────────────────────────
|
|
|
|
override async getInfo(): Promise<NodeInfo> {
|
|
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<Balances> {
|
|
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<Invoice[]> {
|
|
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<Invoice> {
|
|
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<Invoice | null> {
|
|
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<DecodedInvoice> {
|
|
return decodeBolt11(bolt11);
|
|
}
|
|
|
|
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
|
|
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<Payment> {
|
|
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<Payment> {
|
|
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<NwcTransaction | null> {
|
|
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<NwcTransaction | null> {
|
|
if (this.methods.size && !this.methods.has('lookup_invoice')) return null;
|
|
try {
|
|
return await this.lookup(request);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|