Files
bitcoin-wallet/sidecar/backends/nwc.ts
T
officer a4cf939be7 bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision
rather than a mechanical move.

The wallet permission was kind: 'admin' — owner only, not grantable, invisible
to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts
forces it and the manifest has no field for anything else, deliberately, so
that "can a plugin escalate itself" is a question which cannot be asked. So
extraction could not preserve admin, and that was raised as a blocker before
any code moved.

It was the right change anyway, and the objection inverts. Every wallet query
is scoped by user_id, and that id comes from X-Officer-User which the platform
injects from the authenticated session — a caller cannot name someone else's.
So granting this gives an account ITS OWN wallet. Not through the API and not
with root either does it hand over anyone else's, because their seed is sealed
under a passphrase the server never stores. kind: 'admin' was single-user
thinking; grantable is how a second person gets a wallet at all.

Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically,
and the appName drives the route, the API prefix, the table prefix and the
install key together. Tables renamed to bitcoin_wallet_* accordingly — the
prefix guard normalises the hyphen. Free only because no wallet table existed
on any server, which was checked before deciding: db:push treats a rename as a
drop plus a create.

The three custody test files moved with it and still run — 72 tests over
scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened
on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by
nine, so that trap was already closed.

68 palette classes converted, five status vocabularies hoisted into shared.ts.
Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all
read as warning, and the bitcoin and lightning rails no longer differ by hue.

Deferred on purpose: watch-only data (xpubs, balances, history) stays
unencrypted so a locked wallet still shows a balance. Anyone with the database
can therefore SEE any user's balances; they can never move a coin.
2026-08-19 05:18:29 +00:00

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;
}
}
}