import { BackendError, type Balances, type BackendKind, type Capability, type Channel, type CreateInvoiceRequest, type DecodedInvoice, type FeeEstimates, type Invoice, type KeysendRequest, type NewAddressRequest, type NodeInfo, type OnchainTx, type PayInvoiceRequest, type Payment, type Peer, type SendCoinsRequest, type SendCoinsResult, type SignMessageResult, type Utxo, type VerifyMessageResult, type WalletBackend, type AddressType, } from '../types'; /** * Shared backend base. Declares its capability set once and turns every unimplemented method into a * clean 501 — the counterpart to Zeus's `BackendUtils.call()` returning `false` for a missing method * (utils/BackendUtils.ts:56-58), but loud instead of silent. */ export abstract class BaseBackend implements WalletBackend { abstract readonly kind: BackendKind; protected abstract readonly capabilities: ReadonlySet; supports(cap: Capability): boolean { return this.capabilities.has(cap); } protected notSupported(op: string): never { throw new BackendError(`${this.kind} does not support ${op}`, 501, 'NOT_SUPPORTED'); } abstract getInfo(): Promise; abstract getBalances(): Promise; getTransactions(_opts?: { limit?: number }): Promise { return this.notSupported('on-chain transaction history'); } getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> { return this.notSupported('on-chain receive'); } getUtxos(): Promise { return this.notSupported('coin control'); } estimateFees(): Promise { return this.notSupported('fee estimation'); } sendCoins(_req: SendCoinsRequest): Promise { return this.notSupported('on-chain send'); } getInvoices(_opts?: { limit?: number }): Promise { return this.notSupported('invoice listing'); } createInvoice(_req: CreateInvoiceRequest): Promise { return this.notSupported('lightning receive'); } lookupInvoice(_paymentHash: string): Promise { return this.notSupported('invoice lookup'); } decodeInvoice(_bolt11: string): Promise { return this.notSupported('invoice decoding'); } getPayments(_opts?: { limit?: number }): Promise { return this.notSupported('payment history'); } payInvoice(_req: PayInvoiceRequest): Promise { return this.notSupported('lightning send'); } sendKeysend(_req: KeysendRequest): Promise { return this.notSupported('keysend'); } getChannels(): Promise { return this.notSupported('channels'); } getPeers(): Promise { return this.notSupported('peers'); } signMessage(_message: string): Promise { return this.notSupported('message signing'); } verifyMessage(_message: string, _signature: string): Promise { return this.notSupported('message verification'); } } // ── HTTP ───────────────────────────────────────────────────────────────────────────────────────── // Zeus reaches its REST backends through `react-native-blob-util` with a `trusty: true` flag, which // disables TLS verification wholesale so a node with a self-signed cert is reachable (backends/LND.ts). // Bun's fetch takes an equivalent per-request `tls` option, so no extra HTTP client is needed. // // Verification is only relaxed when the owner explicitly opts in for a specific node, and it is a real // trade: it buys reachability for a node with a self-signed cert at the cost of MITM protection on that // connection. Acceptable over loopback or a tailnet, not over the open internet — which is why it is a // per-wallet flag the owner sets deliberately, never a default. type BunFetchInit = RequestInit & { tls?: { rejectUnauthorized?: boolean } }; export type HttpOptions = { method?: string; path: string; base: string; headers?: Record; body?: unknown; query?: Record; allowSelfSigned?: boolean; timeoutMs?: number; /** Return the raw Response instead of parsed JSON. */ raw?: boolean; }; const DEFAULT_TIMEOUT_MS = 30_000; /** The single door every REST backend goes through. Normalizes errors into BackendError. */ export async function httpJson(opts: HttpOptions): Promise { const base = opts.base.replace(/\/+$/, ''); const url = new URL(`${base}${opts.path.startsWith('/') ? opts.path : `/${opts.path}`}`); for (const [k, v] of Object.entries(opts.query ?? {})) { if (v !== undefined) url.searchParams.set(k, String(v)); } const headers: Record = { Accept: 'application/json', ...opts.headers }; let body: string | undefined; if (opts.body !== undefined) { body = JSON.stringify(opts.body); headers['Content-Type'] ??= 'application/json'; } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); const init: BunFetchInit = { method: opts.method ?? 'GET', headers, body, signal: controller.signal, }; if (opts.allowSelfSigned) init.tls = { rejectUnauthorized: false }; let res: Response; try { res = await fetch(url, init); } catch (err) { const msg = controller.signal.aborted ? `timed out after ${opts.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` : String(err); throw new BackendError(`request to ${url.host} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE'); } finally { clearTimeout(timer); } if (opts.raw) return res as unknown as T; const text = await res.text(); let parsed: unknown = null; if (text) { try { parsed = JSON.parse(text); } catch { if (!res.ok) throw new BackendError(`upstream ${res.status}: ${text.slice(0, 200)}`, res.status); throw new BackendError(`upstream returned non-JSON: ${text.slice(0, 200)}`, 502); } } if (!res.ok) { // LND puts {error, code}; CLN puts {message}; LNDHub puts {error, message}. Try all three. const p = parsed as { error?: string; message?: string; code?: number } | null; const msg = p?.error ?? p?.message ?? `upstream returned ${res.status}`; throw new BackendError(msg, res.status, p?.code != null ? String(p.code) : undefined); } return parsed as T; } /** Hex → base64. LND's REST API takes bytes fields base64-encoded; callers hold hex. */ export function hexToBase64(hex: string): string { return Buffer.from(hex, 'hex').toString('base64'); } /** Base64 → hex, for reading LND's bytes fields back out. */ export function base64ToHex(b64: string): string { return Buffer.from(b64, 'base64').toString('hex'); }