// LNDHub — port of Zeus's backends/LndHub.ts (which `extends LND` and overrides ~10 methods). // // LNDHub is a CUSTODIAL account API, not a node. The server holds the keys; this backend is a thin // client over a REST facade (BlueWallet/LndHub, LNbits' lndhub extension, Alby, lntxbot, …). There is // no node identity, no channel view, no UTXO set and no on-chain spend — only a balance, invoices, // payments and (on some deployments) a single deposit address. // // Differences from Zeus, all deliberate: // // 1. AUTH. Zeus logs in from SettingsStore (SettingsStore.ts:2090-2130), parks `access_token` in an // observable and then never refreshes it — an expired token surfaces as a "bad auth" error string // in the UI. Here the token is cached in memory, acquired lazily, and re-acquired transparently on // the first auth failure of any request, through a single-flight promise so N concurrent calls // produce one login rather than N. // 2. ERRORS. LNDHub reports failure as **HTTP 200 with `{error, code, message}`** (Zeus checks for // this ad hoc in three different stores: InvoicesStore.ts:410, TransactionsStore.ts:795). httpJson // only maps non-2xx, so every response goes through `unwrap()` first. Error code 1 ("bad auth") is // re-raised as a 401 so it feeds the same re-auth path as a real 401. // 3. lookupInvoice / getTransactions. Zeus *inherits* LND's `/v1/invoice/:r_hash` and // `/v1/transactions`, neither of which exists on an LNDHub server — those calls simply fail // upstream. lookupInvoice here scans `/getuserinvoices`; on-chain history is read out of the // `bitcoind_tx` entries that `/gettxs` interleaves with lightning payments. // 4. decodeInvoice. Zeus decodes BOLT11 locally (Bolt11Utils). The sidecar has no bolt11 decoder, so // this uses the server's own `/decodeinvoice`, which returns LND's payreq shape. // 5. The `lnurlAuth` signing modes (Alby vs BlueWallet key derivation) are not ported — LNURL-auth is // not part of the WalletBackend contract. // // UNITS. LNDHub is sloppy here and each field has to be taken on its own terms: // • /balance → BTC.AvailableBalance, satoshis // • /getuserinvoices → `amt`, satoshis // • /gettxs (payment) → `value` and `fee`, satoshis, where `value` already includes `fee` // • /gettxs (onchain) → `amount`, **BTC** as a float (verbatim from bitcoind's listtransactions) // • /decodeinvoice → `num_satoshis` and `num_msat` side by side // Everything is normalised to the contract's sats-as-number / msats-as-decimal-string rule on the way out. import { BackendError, type AddressType, type Balances, type BackendKind, type Capability, type CreateInvoiceRequest, type DecodedInvoice, type Invoice, type InvoiceState, type NewAddressRequest, type NodeInfo, type OnchainTx, type PayInvoiceRequest, type Payment, type BitcoinNetwork, } from '../types'; import { BaseBackend, base64ToHex, httpJson } from './base'; import { decodeBolt11 } from '../bolt11'; export type LndHubConfig = { /** Base URL of the LNDHub server, e.g. https://lndhub.example.com or https://ln.getalby.com/lndhub. */ url: string; login: string; password: string; /** Opt-in TLS relaxation for a self-hosted server with a self-signed cert. Never default. */ allowSelfSigned?: boolean; }; // ── upstream wire shapes ───────────────────────────────────────────────────────────────────────── /** Every LNDHub response can be this instead of the documented shape, with HTTP 200. */ type LndHubErrorBody = { error?: string | boolean | number; code?: number; message?: string }; /** node's `JSON.stringify(Buffer)` — LNDHub leaks raw Buffers for hash/preimage fields. */ type LndHubBuffer = { type?: string; data?: number[] }; type LndHubBytes = string | LndHubBuffer; type LndHubAuth = { access_token?: string; refresh_token?: string }; type LndHubBalance = { BTC?: { AvailableBalance?: number; TotalBalance?: number } }; type LndHubGetInfo = { identity_pubkey?: string; alias?: string; version?: string; block_height?: number; testnet?: boolean; synced_to_chain?: boolean; chains?: { chain?: string; network?: string }[]; }; type LndHubUserInvoice = { payment_request?: string; pay_req?: string; r_hash?: LndHubBytes; payment_hash?: LndHubBytes; description?: string; memo?: string; ispaid?: boolean; /** satoshis */ amt?: number | string; amt_paid_sat?: number | string; amt_paid_msat?: number | string; /** seconds of validity, relative to `timestamp` */ expire_time?: number; /** unix seconds */ timestamp?: number | string; settled_at?: number; type?: string; }; /** `/gettxs` interleaves outgoing lightning payments and imported on-chain deposits. */ type LndHubTx = { type?: string; // paid_invoice payment_preimage?: LndHubBytes; payment_hash?: LndHubBytes; payment_request?: string; /** satoshis, and LNDHub folds `fee` into it (User.getTxs) */ value?: number | string; /** satoshis */ fee?: number | string; memo?: string; description?: string; timestamp?: number | string; // bitcoind_tx txid?: string; /** BTC, float, straight from bitcoind */ amount?: number; confirmations?: number; address?: string; category?: string; time?: number; blockheight?: number; }; type LndHubAddress = { address?: string }; type LndHubDecoded = { destination?: string; payment_hash?: string; num_satoshis?: string | number; num_msat?: string | number; timestamp?: string | number; expiry?: string | number; description?: string; cltv_expiry?: string | number; route_hints?: unknown[]; features?: Record; }; /** `/payinvoice` passes LND's sendPaymentSync response through, mostly. */ type LndHubPayResult = { payment_error?: string; payment_preimage?: LndHubBytes; payment_hash?: LndHubBytes; payment_route?: { total_amt?: number | string; total_fees?: number | string; total_amt_msat?: number | string; total_fees_msat?: number | string; }; decoded?: LndHubDecoded; /** some forks answer a zero-amount pay with the amount they chose */ num_satoshis?: number | string; }; type LndHubCall = { path: string; method?: 'GET' | 'POST'; body?: unknown; query?: Record; }; // ── helpers ────────────────────────────────────────────────────────────────────────────────────── const HEX32 = /^[0-9a-f]{64}$/i; /** LNDHub returns a 32-byte field as hex, base64, url-safe base64 or a stringified Buffer. */ function bytesToHex(value: LndHubBytes | undefined | null): string { if (!value) return ''; if (typeof value === 'string') { if (HEX32.test(value)) return value.toLowerCase(); return base64ToHex(value.replace(/-/g, '+').replace(/_/g, '/')); } if (Array.isArray(value.data)) return Buffer.from(value.data).toString('hex'); return ''; } const num = (value: number | string | undefined | null): number => { const n = typeof value === 'string' ? Number(value) : value; return typeof n === 'number' && Number.isFinite(n) ? n : 0; }; const satsToMsat = (sats: number): string => (BigInt(Math.round(sats)) * 1000n).toString(); /** msat string → whole satoshis. LNDHub cannot express sub-satoshi amounts anywhere. */ function msatToSats(msat: string, field: string): number { const value = BigInt(msat); if (value % 1000n !== 0n) { throw new BackendError(`lndhub cannot express sub-satoshi amounts (${field}=${msat}msat)`, 400, 'BAD_AMOUNT'); } return Number(value / 1000n); } /** LNDHub tells us nothing about the address it hands out, so classify it by prefix. */ function addressType(address: string): AddressType { const a = address.toLowerCase(); if (a.startsWith('bc1p') || a.startsWith('tb1p') || a.startsWith('bcrt1p')) return 'p2tr'; if (a.startsWith('bc1') || a.startsWith('tb1') || a.startsWith('bcrt1')) return 'p2wpkh'; if (a.startsWith('3') || a.startsWith('2')) return 'p2sh-p2wpkh'; return 'p2pkh'; } // ── backend ────────────────────────────────────────────────────────────────────────────────────── export class LndHubBackend extends BaseBackend { readonly kind: BackendKind = 'lndhub'; // Custodial: receive and send over lightning, plus a deposit address on deployments that expose // /getbtc. No on-chain send, no coin control, no PSBT, no channels, no peers, no message signing — // the wallet does not hold the keys those would need. protected readonly capabilities: ReadonlySet = new Set([ 'lightningReceive', 'lightningSend', 'onchainReceive', ]); private accessToken: string | null = null; /** In-flight login, shared by every caller that needs a token — the single-flight refresh. */ private pendingLogin: Promise | null = null; constructor(private readonly config: LndHubConfig) { super(); } // ── auth ─────────────────────────────────────────────────────────────────────────────────────── /** * `POST /auth?type=auth {login, password}` → `{access_token, refresh_token}`. The refresh_token * grant (`type=refresh_token`) is deliberately not used: we hold the password, so a fresh login is * one round-trip either way and has one failure mode instead of two. */ private async login(): Promise { const res = await httpJson({ base: this.config.url, path: '/auth', method: 'POST', query: { type: 'auth' }, body: { login: this.config.login, password: this.config.password }, allowSelfSigned: this.config.allowSelfSigned, }); if (res?.error || !res?.access_token) { const message = res?.message ?? (typeof res?.error === 'string' ? res.error : 'lndhub rejected the login'); throw new BackendError(message, 401, 'LNDHUB_AUTH_FAILED'); } this.accessToken = res.access_token; return res.access_token; } private async token(force = false): Promise { if (!force && this.accessToken) return this.accessToken; // Concurrent 401s all await the same login; whoever loses the race gets the winner's token. this.pendingLogin ??= this.login().finally(() => { this.pendingLogin = null; }); return this.pendingLogin; } /** LNDHub's 200-with-`{error}` channel, folded back into the exception path. */ private unwrap(payload: T): T { const body = payload as LndHubErrorBody | null; if (body && typeof body === 'object' && !Array.isArray(body) && body.error) { const message = body.message || (typeof body.error === 'string' ? body.error : 'lndhub returned an error'); // code 1 is LNDHub's "bad auth"; some forks only put the phrase in the message. if (body.code === 1 || /bad auth/i.test(message)) { throw new BackendError(message, 401, 'LNDHUB_BAD_AUTH'); } throw new BackendError(message, 502, body.code != null ? `LNDHUB_${body.code}` : undefined); } return payload; } private async call(req: LndHubCall): Promise { const send = async (token: string): Promise => this.unwrap( await httpJson({ base: this.config.url, path: req.path, method: req.method ?? 'GET', body: req.body, query: req.query, headers: { Authorization: `Bearer ${token}` }, allowSelfSigned: this.config.allowSelfSigned, }), ); try { return await send(await this.token()); } catch (err) { // One retry, and only for auth — a second 401 with a token minted seconds ago is a real failure. if (!(err instanceof BackendError) || err.status !== 401) throw err; this.accessToken = null; return send(await this.token(true)); } } // ── node / balances ──────────────────────────────────────────────────────────────────────────── /** * Best-effort. `/getinfo` exists on BlueWallet-derived servers but not on every fork (Alby, LNbits), * so a failure degrades to an anonymous descriptor rather than breaking connect. Zeus sidesteps this * by reporting `supportsNodeInfo() = false` and never calling it. */ override async getInfo(): Promise { let info: LndHubGetInfo | null = null; try { info = await this.call({ path: '/getinfo' }); } catch { info = null; } const chain = info?.chains?.[0]?.network; const network: BitcoinNetwork = chain === 'testnet' || chain === 'signet' || chain === 'regtest' ? chain : info?.testnet === true ? 'testnet' : 'bitcoin'; return { kind: this.kind, // The account is custodial — the pubkey, if any, belongs to the operator's node, not to us. pubkey: info?.identity_pubkey ?? null, alias: info?.alias ?? null, version: info?.version ?? null, network, blockHeight: info?.block_height ?? null, synced: info?.synced_to_chain ?? true, }; } override async getBalances(): Promise { const res = await this.call({ path: '/balance' }); return { // A custodial account has no on-chain balance of its own; deposits land in the lightning balance. onchainConfirmed: 0, onchainFrozen: 0, onchainUnconfirmed: 0, lightningBalance: num(res?.BTC?.AvailableBalance), // Inbound liquidity is the operator's problem and is never reported. lightningInbound: null, }; } // ── on-chain ─────────────────────────────────────────────────────────────────────────────────── /** * `/gettxs` mixes `paid_invoice` (lightning, outgoing) with `bitcoind_tx` (on-chain deposits copied * out of bitcoind's listtransactions). Only the latter belong here. */ override async getTransactions(opts?: { limit?: number }): Promise { const txs = await this.fetchTxs(opts?.limit); return txs .filter((tx) => tx.type === 'bitcoind_tx' || (!!tx.txid && tx.payment_preimage === undefined)) .map((tx) => ({ txid: tx.txid ?? '', // `amount` here is BTC as a float, not sats. Round after scaling — 0.1 BTC is not exact. amount: Math.round(num(tx.amount) * 1e8), feeSats: null, blockHeight: tx.blockheight ?? null, timestamp: tx.time ?? (tx.timestamp != null ? num(tx.timestamp) : null), confirmations: tx.confirmations ?? 0, label: null, destAddresses: tx.address ? [tx.address] : [], rawHex: null, })); } /** * `/getbtc` returns the account's single deposit address as a one-element array (Zeus reads * `data[0].address`, InvoicesStore.ts:620). It is static per account, so `peek` is meaningless and * the requested address type is ignored — the operator chose it. On the deployments that hand back an * empty array until an address has been allocated, `/newbtc` allocates one. */ override async getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> { let address = await this.fetchBtcAddress(); if (!address) { await this.call({ path: '/newbtc', method: 'POST', body: {} }); address = await this.fetchBtcAddress(); } if (!address) { throw new BackendError('lndhub server does not offer on-chain deposits', 501, 'NOT_SUPPORTED'); } return { address, type: addressType(address) }; } private async fetchBtcAddress(): Promise { const res = await this.call({ path: '/getbtc' }); if (Array.isArray(res)) return res[0]?.address ?? ''; return res?.address ?? ''; } // ── lightning ────────────────────────────────────────────────────────────────────────────────── override async getInvoices(opts?: { limit?: number }): Promise { const res = await this.call({ path: '/getuserinvoices', query: { limit: opts?.limit }, }); return (Array.isArray(res) ? res : []).map((inv) => this.toInvoice(inv)); } override async createInvoice(req: CreateInvoiceRequest): Promise { if (req.preimage) return this.notSupported('custom preimages'); if (req.isAmp) return this.notSupported('AMP invoices'); // LNDHub answers a zero-amount invoice request with "Bad arguments" (Zeus special-cases the string // in InvoicesStore.ts:425); reject it here with something legible instead. if (!req.amountMsat || req.amountMsat === '0') { throw new BackendError('lndhub requires an invoice amount', 400, 'AMOUNT_REQUIRED'); } const sats = msatToSats(req.amountMsat, 'amountMsat'); // `expirySeconds` and `private` have no equivalent — the server picks both (Zeus: // supportsSettingInvoiceExpiration() = false). const res = await this.call({ path: '/addinvoice', method: 'POST', body: { amt: String(sats), memo: req.memo ?? '' }, }); const bolt11 = res.payment_request ?? res.pay_req ?? ''; let paymentHash = bytesToHex(res.r_hash ?? res.payment_hash); // A few forks omit r_hash on /addinvoice. The hash is already in the invoice we were just handed, so // read it locally rather than spending a second authenticated round-trip — and a second failure mode // — on `/decodeinvoice`, which those same forks are the least likely to implement. if (!paymentHash && bolt11) { paymentHash = decodeBolt11(bolt11).paymentHash; } const createdAt = res.timestamp != null ? num(res.timestamp) : Math.floor(Date.now() / 1000); return { paymentHash, bolt11, amountMsat: satsToMsat(sats), amountPaidMsat: null, memo: req.memo ?? res.description ?? null, state: 'open', createdAt, expiresAt: res.expire_time ? createdAt + res.expire_time : null, settledAt: null, preimage: null, isKeysend: false, isAmp: false, }; } /** * LNDHub has no lookup endpoint — Zeus inherits LND's `/v1/invoice/:r_hash`, which 404s here. Scan * the invoice list instead. Bounded at 200 entries: an older invoice reports as not found rather * than paging the whole history on every poll. */ override async lookupInvoice(paymentHash: string): Promise { const wanted = paymentHash.toLowerCase(); const res = await this.call({ path: '/getuserinvoices', query: { limit: 200 } }); const found = (Array.isArray(res) ? res : []).find((inv) => bytesToHex(inv.r_hash ?? inv.payment_hash) === wanted); return found ? this.toInvoice(found) : null; } /** * Zeus decodes BOLT11 client-side (Bolt11Utils) and never calls this endpoint. The sidecar has no * decoder, and the server's `/decodeinvoice?invoice=` answers with LND's payreq shape, which maps * onto DecodedInvoice one-for-one. */ override async decodeInvoice(bolt11: string): Promise { const res = await this.call({ path: '/decodeinvoice', query: { invoice: bolt11 } }); const msat = res.num_msat != null ? String(num(res.num_msat)) : null; return { bolt11, paymentHash: res.payment_hash ?? '', amountMsat: msat ?? (res.num_satoshis != null ? satsToMsat(num(res.num_satoshis)) : null), description: res.description ?? null, destination: res.destination ?? '', timestamp: num(res.timestamp), expiry: res.expiry != null ? num(res.expiry) : 3600, cltvExpiry: res.cltv_expiry != null ? num(res.cltv_expiry) : null, routeHints: Array.isArray(res.route_hints) && res.route_hints.length > 0, features: Object.values(res.features ?? {}).map((f) => f?.name ?? ''), }; } override async getPayments(opts?: { limit?: number }): Promise { const txs = await this.fetchTxs(opts?.limit); return txs .filter((tx) => tx.type === 'paid_invoice' || tx.payment_preimage !== undefined) .map((tx) => { // LNDHub's User.getTxs() sets `value = payment_route.total_amt + payment_route.total_fees`, // so the destination amount is `value - fee`. Both are satoshis. const feeSats = num(tx.fee); const amountSats = Math.max(num(tx.value) - feeSats, 0); return { paymentHash: bytesToHex(tx.payment_hash), preimage: bytesToHex(tx.payment_preimage) || null, amountMsat: satsToMsat(amountSats), feeMsat: satsToMsat(feeSats), // /gettxs only records payments that went through; failures are never persisted. status: 'succeeded' as const, createdAt: num(tx.timestamp ?? tx.time), destination: null, memo: tx.memo ?? tx.description ?? null, failureReason: null, }; }); } /** * `POST /payinvoice {invoice, amount}` — `amount` is satoshis and only consulted for a zero-amount * invoice. Fee limits and timeouts are the operator's policy (Zeus: supportsCustomFeeLimit() = * false), so feeLimitMsat / feeLimitPercent / timeoutSeconds are accepted and ignored. */ override async payInvoice(req: PayInvoiceRequest): Promise { const amountSats = req.amountMsat ? msatToSats(req.amountMsat, 'amountMsat') : undefined; const res = await this.call({ path: '/payinvoice', method: 'POST', body: { invoice: req.bolt11, amount: amountSats }, }); const route = res.payment_route ?? {}; const feeMsat = route.total_fees_msat != null ? String(num(route.total_fees_msat)) : satsToMsat(num(route.total_fees)); // Prefer the invoice's own amount: `total_amt` is ambiguous across forks about whether fees are // included, whereas the decoded payreq is not. const decodedMsat = res.decoded?.num_msat != null ? String(num(res.decoded.num_msat)) : res.decoded?.num_satoshis != null ? satsToMsat(num(res.decoded.num_satoshis)) : null; const routedMsat = route.total_amt_msat != null ? String(num(route.total_amt_msat)) : satsToMsat(num(route.total_amt)); const preimage = bytesToHex(res.payment_preimage) || null; // Hard failures arrive as `{error, message}` and have already thrown in unwrap(). `payment_error` // is LND's soft channel — a routing failure, reported as a failed Payment rather than an exception. const failed = !!res.payment_error; return { paymentHash: bytesToHex(res.payment_hash) || res.decoded?.payment_hash || '', preimage, amountMsat: decodedMsat ?? req.amountMsat ?? routedMsat, feeMsat, status: failed ? 'failed' : 'succeeded', createdAt: Math.floor(Date.now() / 1000), destination: res.decoded?.destination ?? null, memo: res.decoded?.description ?? null, failureReason: failed ? (res.payment_error ?? null) : null, }; } // ── shared ───────────────────────────────────────────────────────────────────────────────────── private async fetchTxs(limit?: number): Promise { const res = await this.call({ path: '/gettxs', query: { limit } }); return Array.isArray(res) ? res : []; } private toInvoice(inv: LndHubUserInvoice): Invoice { const createdAt = num(inv.timestamp); const expiresAt = inv.expire_time ? createdAt + inv.expire_time : null; const paid = inv.ispaid === true; // LNDHub has no cancel and no hold invoices, so 'canceled' and 'accepted' are unreachable; an // unpaid invoice past its expiry is reported as expired rather than left open. const state: InvoiceState = paid ? 'settled' : expiresAt != null && expiresAt < Math.floor(Date.now() / 1000) ? 'expired' : 'open'; const amountMsat = inv.amt != null ? satsToMsat(num(inv.amt)) : null; const paidMsat = inv.amt_paid_msat != null ? String(num(inv.amt_paid_msat)) : inv.amt_paid_sat != null ? satsToMsat(num(inv.amt_paid_sat)) : paid ? amountMsat : null; return { paymentHash: bytesToHex(inv.r_hash ?? inv.payment_hash), bolt11: inv.payment_request ?? inv.pay_req ?? '', amountMsat, amountPaidMsat: paidMsat, memo: inv.description ?? inv.memo ?? null, state, createdAt, expiresAt, // The settle time is not recorded upstream; only the ispaid flag is. settledAt: inv.settled_at ?? null, // Preimages of received payments stay with the custodian. preimage: null, isKeysend: false, isAmp: false, }; } }