add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
// 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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user