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:
2026-07-31 06:48:06 +00:00
co-authored by Claude Opus 5
parent 5ee56e736b
commit f8826e4c24
69 changed files with 11867 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
import { createRouter } from '../../create-router';
import { getWalletServerUrl } from './sidecar-server';
// Thin reverse-proxy for /api/wallet/*. The platform's ONLY job here is AUTH + FORWARDING: this router
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards
// the subpath + query + body to the officer-wallet sidecar, which owns every wallet contract and holds
// the key material.
//
// A catch-all with no routes of its own. This file must never grow wallet logic — and for this sidecar
// that rule carries more weight than usual. The platform process is long-lived, restarts on every
// deploy, and is the largest attack surface in the system. Keeping it structurally incapable of seeing a
// seed, a macaroon, or an unlock passphrase is the entire design.
//
// The unlock passphrase DOES transit this proxy on its way to the sidecar. That is unavoidable — the
// browser has to send it somewhere — but it is forwarded as an opaque body and never logged, never
// parsed, and never retained here. Note the deliberate absence of any body inspection below.
export const walletRouter = createRouter();
const PREFIX = '/api/wallet';
walletRouter.all('/*', async (ctx) => {
const baseUrl = getWalletServerUrl();
if (!baseUrl) return ctx.text('wallet sidecar not available', 503);
const url = new URL(ctx.req.url);
const subpath = url.pathname.slice(PREFIX.length) || '/';
const target = `${baseUrl}${subpath}${url.search}`;
const method = ctx.req.method;
const headers: Record<string, string> = {};
const contentType = ctx.req.header('content-type');
if (contentType) headers['Content-Type'] = contentType;
// Forward the authenticated user id so the sidecar can scope every wallet to its owner. The sidecar
// binds loopback only, so this header is trusted.
headers['X-Officer-User'] = String(ctx.get('user').id);
const hasBody = method !== 'GET' && method !== 'HEAD';
let upstream: Response;
try {
upstream = await fetch(target, {
method,
headers,
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
});
} catch (err) {
// Deliberately logs the target path only — never the body, which may carry a passphrase.
console.error('[wallet] proxy fetch failed', { target, error: String(err) });
return ctx.text('wallet sidecar unreachable', 502);
}
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
});
+20
View File
@@ -0,0 +1,20 @@
import * as sidecar from '@@/sidecar-registry';
// The officer-wallet sidecar starts its HTTP server on a random loopback port and reports it here on
// connect. We remember it so `/api/wallet/*` always forwards to the current sidecar. The platform holds
// NO wallet knowledge whatsoever — not a seed, not a node credential, not an xpub. It cannot spend, and
// it cannot read a balance except by asking the sidecar.
let serverPort: number | null = null;
sidecar.on('wallet:server', (msg) => {
const port = (msg as { port?: number }).port;
if (typeof port !== 'number') return;
serverPort = port;
console.log(`[wallet] sidecar registered on port ${port}`);
});
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
export function getWalletServerUrl(): string | null {
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
}
+3
View File
@@ -25,6 +25,7 @@ import { slskdRouter } from './api/slskd/router';
import { headscaleRouter } from './api/headscale/router';
import { transmissionRouter } from './api/transmission/router';
import { invoiceshelfRouter } from './api/invoiceshelf/router';
import { walletRouter } from './api/wallet/router';
import { vpnRouter } from './api/vpn/router';
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
import { activityRouter } from './api/activity/router';
@@ -34,6 +35,7 @@ import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd r
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port
import './api/invoiceshelf/sidecar-server'; // side-effect: capture the officer-invoiceshelf server port
import './api/wallet/sidecar-server'; // side-effect: capture the officer-wallet server port
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
import { dockRouter } from './api/dock/dock';
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
@@ -119,6 +121,7 @@ protectedRouter.route('/slskd', slskdRouter);
protectedRouter.route('/headscale', headscaleRouter);
protectedRouter.route('/transmission', transmissionRouter);
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
protectedRouter.route('/wallet', walletRouter);
protectedRouter.route('/vpn', vpnRouter);
protectedRouter.route('/system-monitor', systemMonitorRouter);
protectedRouter.route('/activity', activityRouter);
+2
View File
@@ -74,6 +74,8 @@ export type SidecarEvent =
| { type: 'transmission:server'; port: number }
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'invoiceshelf:server'; port: number }
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
| { type: 'wallet:server'; port: number }
// Generic
| { type: 'error'; id?: string; error: string };
+194
View File
@@ -0,0 +1,194 @@
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<Capability>;
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<NodeInfo>;
abstract getBalances(): Promise<Balances>;
getTransactions(_opts?: { limit?: number }): Promise<OnchainTx[]> {
return this.notSupported('on-chain transaction history');
}
getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
return this.notSupported('on-chain receive');
}
getUtxos(): Promise<Utxo[]> {
return this.notSupported('coin control');
}
estimateFees(): Promise<FeeEstimates> {
return this.notSupported('fee estimation');
}
sendCoins(_req: SendCoinsRequest): Promise<SendCoinsResult> {
return this.notSupported('on-chain send');
}
getInvoices(_opts?: { limit?: number }): Promise<Invoice[]> {
return this.notSupported('invoice listing');
}
createInvoice(_req: CreateInvoiceRequest): Promise<Invoice> {
return this.notSupported('lightning receive');
}
lookupInvoice(_paymentHash: string): Promise<Invoice | null> {
return this.notSupported('invoice lookup');
}
decodeInvoice(_bolt11: string): Promise<DecodedInvoice> {
return this.notSupported('invoice decoding');
}
getPayments(_opts?: { limit?: number }): Promise<Payment[]> {
return this.notSupported('payment history');
}
payInvoice(_req: PayInvoiceRequest): Promise<Payment> {
return this.notSupported('lightning send');
}
sendKeysend(_req: KeysendRequest): Promise<Payment> {
return this.notSupported('keysend');
}
getChannels(): Promise<Channel[]> {
return this.notSupported('channels');
}
getPeers(): Promise<Peer[]> {
return this.notSupported('peers');
}
signMessage(_message: string): Promise<SignMessageResult> {
return this.notSupported('message signing');
}
verifyMessage(_message: string, _signature: string): Promise<VerifyMessageResult> {
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<string, string>;
body?: unknown;
query?: Record<string, string | number | boolean | undefined>;
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<T = unknown>(opts: HttpOptions): Promise<T> {
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<string, string> = { 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');
}
File diff suppressed because it is too large Load Diff
+859
View File
@@ -0,0 +1,859 @@
// LND REST backend — ported from Zeus's `backends/LND.ts` (+ `utils/LndUtils.ts` for the address-type
// mapping). Zeus's class is a thin, untyped RPC shim: every method returns the upstream JSON verbatim and
// the view layer does the unit conversion. Here the conversion happens once, at the edge, against the
// `WalletBackend` contract in `../types.ts`.
//
// INTENTIONAL BEHAVIOURAL DIFFERENCES FROM ZEUS
//
// 1. No Tor. Zeus routes through `doTorRequest` when `enableTor` is set. A sidecar on the owner's own box
// reaches its node directly; if Tor is ever needed it belongs in the dispatcher, not per backend.
// 2. No in-flight call de-duplication. Zeus keys a module-level `calls` Map by url+body so a second
// identical request joins the first, and needs `clearCachedCalls()` to escape a poisoned entry. That
// cache is a mobile-battery optimisation and a footgun (a failed call can be re-awaited); dropped.
// 3. No `forcedTimeout` race. Zeus races `payLightningInvoice` against a promise that RESOLVES with a fake
// `{payment_error: 'timed out'}` after timeout+1s — so a slow payment silently reports failure while
// still in flight. We pass a real deadline to `httpJson` and let it abort, surfacing a 502.
// 4. TLS verification is opt-in per node (`allowSelfSigned`), not the blanket `trusty: !certVerification`.
// 5. Version gating is OPTIMISTIC until `getInfo()` runs. Zeus reads `nodeInfoStore.version`, which is
// empty before the first getinfo, so every version-gated `supportsX()` answers false. Here the gated
// capabilities (coinControl, accounts, offers) start ON and are switched OFF once getInfo reports an
// older node — a pre-getInfo call to an old node fails upstream instead of 501-ing on a modern one.
// 6. `offers` is declared for v0.18+. Zeus hardcodes `supportsOffers = () => false` for LND.
// 7. Websocket-only endpoints are not ported: `openChannelStream`, `initChanAcceptor`,
// `subscribeCustomMessages`, `subscribeInvoice`, `subscribeTransactions`. `WalletBackend` has no
// streaming surface.
// 8. `sendCoins` reports `feeSats: 0`. LND's SendCoinsResponse carries only `txid`; the fee is only
// observable afterwards through GetTransactions, and we do not block the send on a second round trip.
// 9. `SendCoinsRequest.rbf` is ignored — lnrpc.SendCoins has no replaceability flag to forward.
// 10. `Utxo.derivationPath` is always null. Zeus reads paths from a separate ListAddresses call (v0.18+);
// joining that per-UTXO here would double the request count for a field nothing currently consumes.
// 11. `Peer.alias` is always null, matching Zeus's `listPeers` — /v1/peers has no alias, and resolving one
// costs a graph lookup per peer. Channels DO get an alias, via ListChannels' own `peer_alias_lookup`.
// 12. `InvoiceState.expired` is derived (unsettled + past `creation_date + expiry`); lnrpc's Invoice.state
// enum has no EXPIRED member.
// 13. `estimateFees` uses walletrpc EstimateFee. Zeus does not ask LND for on-chain fees at all — it reads
// mempool.space in the app layer.
import { createHash, randomBytes } from 'node:crypto';
import {
BackendError,
type AddressType,
type Balances,
type BackendKind,
type BitcoinNetwork,
type Capability,
type Channel,
type CreateInvoiceRequest,
type DecodedInvoice,
type FeeEstimates,
type Invoice,
type InvoiceState,
type KeysendRequest,
type NewAddressRequest,
type NodeInfo,
type OnchainTx,
type PayInvoiceRequest,
type Payment,
type PaymentStatus,
type Peer,
type SendCoinsRequest,
type SendCoinsResult,
type SignMessageResult,
type Utxo,
type VerifyMessageResult,
} from '../types';
import { BaseBackend, base64ToHex, hexToBase64, httpJson } from './base';
// ── upstream wire types ──────────────────────────────────────────────────────────────────────────
//
// Field names are lnrpc's own, verbatim. grpc-gateway renders every int64 as a decimal STRING and every
// `bytes` field as base64 — both quirks are load-bearing below, so the types record them exactly.
type LndChain = { chain?: string; network?: string };
type LndGetInfo = {
version?: string;
identity_pubkey?: string;
alias?: string;
block_height?: number;
synced_to_chain?: boolean;
testnet?: boolean;
chains?: LndChain[];
};
type LndBlockchainBalance = {
total_balance?: string;
confirmed_balance?: string;
unconfirmed_balance?: string;
};
/** lnrpc.Amount — the same sat/msat pair LND uses everywhere it reports a channel-side balance. */
type LndAmount = { sat?: string; msat?: string };
type LndChannelBalance = {
balance?: string;
local_balance?: LndAmount;
remote_balance?: LndAmount;
};
type LndTransaction = {
tx_hash?: string;
amount?: string;
num_confirmations?: number;
block_height?: number;
time_stamp?: string;
total_fees?: string;
dest_addresses?: string[];
raw_tx_hex?: string;
label?: string;
};
type LndUtxo = {
address_type?: string;
address?: string;
amount_sat?: string;
confirmations?: string;
outpoint?: { txid_str?: string; output_index?: number };
};
type LndEstimateFee = { sat_per_kw?: string; min_relay_fee_sat_per_kw?: string };
type LndInvoice = {
memo?: string;
r_preimage?: string;
r_hash?: string;
value?: string;
value_msat?: string;
creation_date?: string;
settle_date?: string;
payment_request?: string;
expiry?: string;
amt_paid_msat?: string;
state?: string;
is_keysend?: boolean;
is_amp?: boolean;
};
type LndAddInvoiceResponse = { r_hash?: string; payment_request?: string; payment_addr?: string };
type LndFeature = { name?: string; is_required?: boolean; is_known?: boolean };
type LndPayReq = {
destination?: string;
payment_hash?: string;
num_msat?: string;
num_satoshis?: string;
timestamp?: string;
expiry?: string;
description?: string;
cltv_expiry?: string;
route_hints?: unknown[];
features?: Record<string, LndFeature>;
};
type LndHop = { pub_key?: string };
type LndHtlcAttempt = { route?: { hops?: LndHop[] } };
type LndPayment = {
payment_hash?: string;
payment_preimage?: string;
value_msat?: string;
fee_msat?: string;
status?: string;
creation_date?: string;
creation_time_ns?: string;
failure_reason?: string;
htlcs?: LndHtlcAttempt[];
};
/** One newline-delimited frame of the SendPaymentV2 stream. */
type LndRouterFrame = { result?: LndPayment; error?: { message?: string; code?: number } };
type LndChannel = {
chan_id?: string;
channel_point?: string;
remote_pubkey?: string;
peer_alias?: string;
capacity?: string;
local_balance?: string;
remote_balance?: string;
active?: boolean;
private?: boolean;
};
type LndPendingChannel = {
remote_node_pub?: string;
channel_point?: string;
capacity?: string;
local_balance?: string;
remote_balance?: string;
private?: boolean;
};
type LndPendingChannels = {
pending_open_channels?: { channel?: LndPendingChannel }[];
pending_force_closing_channels?: { channel?: LndPendingChannel }[];
waiting_close_channels?: { channel?: LndPendingChannel }[];
};
type LndPeer = { pub_key?: string; address?: string; inbound?: boolean };
// ── constants ────────────────────────────────────────────────────────────────────────────────────
/** Zeus's LndUtils.LNRPC_NEW_ADDRESS_TYPE_NAMES, verbatim. See that file for why each row exists. */
const LNRPC_NEW_ADDRESS_TYPE_NAMES: Record<string, string> = {
'0': 'WITNESS_PUBKEY_HASH',
'1': 'NESTED_PUBKEY_HASH',
'2': 'UNUSED_WITNESS_PUBKEY_HASH',
'3': 'UNUSED_NESTED_PUBKEY_HASH',
'4': 'TAPROOT_PUBKEY',
'5': 'UNUSED_TAPROOT_PUBKEY',
NESTED_WITNESS_PUBKEY_HASH: 'NESTED_PUBKEY_HASH',
HYBRID_NESTED_WITNESS_PUBKEY_HASH: 'NESTED_PUBKEY_HASH',
};
/**
* Zeus's `toLnrpcAddressType`. The numeric strings matter: LND REST's grpc-gateway silently treats an
* unrecognised `type` as WITNESS_PUBKEY_HASH, so sending '1' quietly yields a native segwit address.
*/
function toLnrpcAddressType(value: string | number | undefined | null): string | undefined {
if (value == null) return undefined;
const key = String(value);
return LNRPC_NEW_ADDRESS_TYPE_NAMES[key] ?? key;
}
/** Our AddressType → the lnrpc enum index, in normal and `peek` (UNUSED_*, non-advancing) form. */
const ADDRESS_TYPE_INDEX: Record<AddressType, { fresh: string; peek: string } | null> = {
p2wpkh: { fresh: '0', peek: '2' },
'p2sh-p2wpkh': { fresh: '1', peek: '3' },
p2tr: { fresh: '4', peek: '5' },
// lnrpc.NewAddress has no legacy p2pkh member — LND has never handed out a base58 receive address.
p2pkh: null,
};
/** walletrpc's AddressType enum (ListUnspent) → ours. The HYBRID_ variant is still a p2sh-p2wpkh. */
const WALLETRPC_ADDRESS_TYPE: Record<string, AddressType> = {
WITNESS_PUBKEY_HASH: 'p2wpkh',
NESTED_WITNESS_PUBKEY_HASH: 'p2sh-p2wpkh',
HYBRID_NESTED_WITNESS_PUBKEY_HASH: 'p2sh-p2wpkh',
TAPROOT_PUBKEY: 'p2tr',
};
/** BOLT spec TLV records for keysend: the preimage the receiver settles with, and the free-text message. */
const KEYSEND_PREIMAGE_RECORD = '5482373484';
const KEYSEND_MESSAGE_RECORD = '34349334';
/** A 32-byte all-zero preimage is LND's "not settled yet" placeholder, not a real preimage. */
const ZERO_PREIMAGE_HEX = '0'.repeat(64);
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
/** int64-as-string → number. Used only for sat-denominated fields, which cannot overflow a double. */
function toSats(value: string | number | undefined | null): number {
if (value == null || value === '') return 0;
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
/** int64-as-string → msat decimal string. Never widened to a number: 2.1e18 does not fit a double. */
function toMsat(value: string | number | undefined | null): string {
if (value == null || value === '') return '0';
return typeof value === 'number' ? Math.round(value).toString() : value;
}
/** `0.18.3-beta commit=v0.18.3-beta` → [0, 18, 3]. Zeus does the same in VersionUtils. */
function parseVersion(version: string | null): [number, number, number] | null {
if (!version) return null;
const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3])];
}
/** Unknown version counts as "new enough" — see difference 5 in the header. */
function atLeast(version: string | null, min: string): boolean {
const have = parseVersion(version);
const want = parseVersion(min);
if (!have || !want) return true;
for (let i = 0; i < 3; i++) {
const h = have[i] ?? 0;
const w = want[i] ?? 0;
if (h !== w) return h > w;
}
return true;
}
function toNetwork(info: LndGetInfo): BitcoinNetwork {
const raw = info.chains?.[0]?.network ?? (info.testnet ? 'testnet' : 'mainnet');
switch (raw) {
case 'testnet':
case 'testnet3':
case 'testnet4':
return 'testnet';
case 'signet':
return 'signet';
// simnet is btcd's private-chain mode; it behaves like regtest for everything we surface.
case 'regtest':
case 'simnet':
return 'regtest';
default:
return 'bitcoin';
}
}
function toPaymentStatus(status: string | undefined): PaymentStatus {
switch (status) {
case 'SUCCEEDED':
return 'succeeded';
case 'FAILED':
return 'failed';
// UNKNOWN / IN_FLIGHT / INITIATED all mean "not resolved yet".
default:
return 'pending';
}
}
export type LndConfig = {
/** Full base URL including scheme and port, e.g. `https://192.168.1.5:8080`. */
url: string;
/** Admin (or narrower) macaroon, hex. Sent as `Grpc-Metadata-macaroon`. */
macaroonHex: string;
allowSelfSigned?: boolean;
};
// ── the backend ──────────────────────────────────────────────────────────────────────────────────
export class LndBackend extends BaseBackend {
readonly kind: BackendKind = 'lnd';
// Mutated in place by `applyVersionGates`; `capabilities` is the same object seen read-only.
private readonly caps = new Set<Capability>([
'onchainReceive',
'onchainSend',
'coinControl',
'psbt',
'bumpFee',
'sweep',
'accounts',
'lightningReceive',
'lightningSend',
'keysend',
'customPreimages',
'offers',
'channels',
'peers',
'routing',
'signMessage',
]);
protected readonly capabilities: ReadonlySet<Capability> = this.caps;
constructor(private readonly config: LndConfig) {
super();
}
// ── transport ──────────────────────────────────────────────────────────────────────────────────
private get<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<T> {
return httpJson<T>({
base: this.config.url,
path,
query,
method: 'GET',
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
allowSelfSigned: this.config.allowSelfSigned,
});
}
private post<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
return httpJson<T>({
base: this.config.url,
path,
method: 'POST',
body: body ?? {},
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
allowSelfSigned: this.config.allowSelfSigned,
timeoutMs,
});
}
/**
* SendPaymentV2 (`/v2/router/send`) is a SERVER-STREAMING rpc. grpc-gateway renders it as
* newline-delimited JSON — one `{"result": …}` frame per payment state change — which `JSON.parse` on
* the whole body cannot read. Zeus handles this in `restReq` by splitting on '\n' and taking
* `split[length - 2]` (the body has a trailing newline); we take the last non-empty line, which is the
* same frame without depending on the trailing newline being there.
*
* We also set `no_inflight_updates`, so in practice there is exactly one frame: the terminal one.
*/
private async routerSend(body: Record<string, unknown>, timeoutMs: number): Promise<LndPayment> {
const res = await httpJson<Response>({
base: this.config.url,
path: '/v2/router/send',
method: 'POST',
body,
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
allowSelfSigned: this.config.allowSelfSigned,
timeoutMs,
raw: true,
});
const text = await res.text();
const lines = text.split('\n').filter((line) => line.trim().length > 0);
const last = lines[lines.length - 1];
if (!last) {
throw new BackendError(`router/send returned an empty body (HTTP ${res.status})`, res.ok ? 502 : res.status);
}
let frame: LndRouterFrame;
try {
frame = JSON.parse(last) as LndRouterFrame;
} catch {
throw new BackendError(`router/send returned non-JSON: ${last.slice(0, 200)}`, res.ok ? 502 : res.status);
}
// A stream error arrives as a frame, not as a non-2xx status, so check it before `res.ok`.
if (frame.error) {
throw new BackendError(
frame.error.message ?? 'payment failed',
res.ok ? 502 : res.status,
frame.error.code != null ? String(frame.error.code) : undefined,
);
}
if (!res.ok || !frame.result) {
throw new BackendError(`router/send failed: ${last.slice(0, 200)}`, res.ok ? 502 : res.status);
}
return frame.result;
}
/** Zeus's version predicates, evaluated once per getInfo instead of once per `supportsX()` call. */
private applyVersionGates(version: string | null): void {
const gate = (cap: Capability, min: string) => {
if (atLeast(version, min)) this.caps.add(cap);
else this.caps.delete(cap);
};
gate('coinControl', '0.12.0'); // Zeus: supportsCoinControl
gate('accounts', '0.13.0'); // Zeus: supportsAccounts
gate('offers', '0.18.0'); // Zeus hardcodes false; see difference 6
}
// ── node / balances ────────────────────────────────────────────────────────────────────────────
override async getInfo(): Promise<NodeInfo> {
const info = await this.get<LndGetInfo>('/v1/getinfo');
const version = info.version ?? null;
this.applyVersionGates(version);
return {
kind: this.kind,
pubkey: info.identity_pubkey ?? null,
alias: info.alias ?? null,
version,
network: toNetwork(info),
blockHeight: info.block_height ?? null,
synced: info.synced_to_chain === true,
};
}
override async getBalances(): Promise<Balances> {
const [chain, channels] = await Promise.all([
this.get<LndBlockchainBalance>('/v1/balance/blockchain'),
this.get<LndChannelBalance>('/v1/balance/channels'),
]);
return {
onchainConfirmed: toSats(chain.confirmed_balance),
onchainUnconfirmed: toSats(chain.unconfirmed_balance),
// `balance` is the deprecated flat field; the nested Amount is authoritative on v0.11+.
lightningBalance: toSats(channels.local_balance?.sat ?? channels.balance),
lightningInbound: toSats(channels.remote_balance?.sat),
};
}
// ── on-chain ───────────────────────────────────────────────────────────────────────────────────
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
// `end_height=-1` is LND's "up to and including unconfirmed" sentinel — without it the mempool is
// excluded and a just-broadcast send is invisible.
const res = await this.get<{ transactions?: LndTransaction[] }>('/v1/transactions', {
end_height: -1,
max_transactions: opts?.limit ?? 500,
});
return (res.transactions ?? []).map((tx) => ({
txid: tx.tx_hash ?? '',
amount: toSats(tx.amount),
feeSats: tx.total_fees != null ? toSats(tx.total_fees) : null,
blockHeight: tx.block_height ? tx.block_height : null,
timestamp: tx.time_stamp ? toSats(tx.time_stamp) : null,
confirmations: tx.num_confirmations ?? 0,
label: tx.label ? tx.label : null,
destAddresses: tx.dest_addresses ?? [],
rawHex: tx.raw_tx_hex ?? null,
}));
}
override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
const type: AddressType = req?.type ?? 'p2wpkh';
const index = ADDRESS_TYPE_INDEX[type];
if (!index) throw new BackendError(`lnd cannot derive a ${type} address`, 400, 'UNSUPPORTED_ADDRESS_TYPE');
// `peek` maps onto the UNUSED_* enum members, which return the current address without advancing the
// derivation index — LND's only form of "show me the same address again".
const lnrpcType = toLnrpcAddressType(req?.peek ? index.peek : index.fresh);
const res = await this.get<{ address?: string }>('/v1/newaddress', { type: lnrpcType });
if (!res.address) throw new BackendError('lnd returned no address', 502);
return { address: res.address, type };
}
override async getUtxos(): Promise<Utxo[]> {
// max_confs must be set explicitly: walletrpc defaults it to 0, which matches nothing.
const res = await this.post<{ utxos?: LndUtxo[] }>('/v2/wallet/utxos', {
min_confs: 0,
max_confs: 0x7fffffff,
});
return (res.utxos ?? []).map((utxo) => ({
txid: utxo.outpoint?.txid_str ?? '',
vout: utxo.outpoint?.output_index ?? 0,
amountSats: toSats(utxo.amount_sat),
address: utxo.address ?? '',
addressType: utxo.address_type ? (WALLETRPC_ADDRESS_TYPE[utxo.address_type] ?? null) : null,
confirmations: toSats(utxo.confirmations),
derivationPath: null,
// ListUnspent already omits leased outputs, so anything we can see here is selectable.
frozen: false,
}));
}
override async estimateFees(): Promise<FeeEstimates> {
// walletrpc quotes sat/kw (weight units). 1 vB = 4 wu, so sat/vB = sat_per_kw / 250.
const perVbyte = (satPerKw: string | undefined): number => Math.max(1, Math.ceil(toSats(satPerKw) / 250));
const targets = [2, 3, 6, 144] as const;
const [fastest, halfHour, hour, economy] = await Promise.all(
targets.map((target) => this.get<LndEstimateFee>(`/v2/wallet/estimatefee/${target}`)),
);
return {
fastestFee: perVbyte(fastest?.sat_per_kw),
halfHourFee: perVbyte(halfHour?.sat_per_kw),
hourFee: perVbyte(hour?.sat_per_kw),
economyFee: perVbyte(economy?.sat_per_kw),
// min_relay_fee_sat_per_kw only exists on v0.18+; 253 sat/kw is bitcoind's floor (~1 sat/vB).
minimumFee: perVbyte(fastest?.min_relay_fee_sat_per_kw ?? '253'),
};
}
override async sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult> {
if (req.outpoints?.length && !this.supports('coinControl')) this.notSupported('coin control');
const body: Record<string, unknown> = {
addr: req.address,
sat_per_vbyte: String(req.satPerVbyte),
spend_unconfirmed: req.spendUnconfirmed === true,
label: req.label,
};
if (req.sendAll) body.send_all = true;
else body.amount = String(req.amountSats ?? 0);
if (req.outpoints?.length) {
body.outpoints = req.outpoints.map((outpoint) => {
const [txid, vout] = outpoint.split(':');
return { txid_str: txid, output_index: Number(vout ?? 0) };
});
}
const res = await this.post<{ txid?: string }>('/v1/transactions', body);
if (!res.txid) throw new BackendError('lnd returned no txid', 502);
return { txid: res.txid, feeSats: 0, rawHex: null };
}
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
private toInvoice(inv: LndInvoice): Invoice {
const createdAt = toSats(inv.creation_date);
const expiry = toSats(inv.expiry);
const expiresAt = expiry > 0 ? createdAt + expiry : null;
const settledAt = toSats(inv.settle_date);
const preimageHex = inv.r_preimage ? base64ToHex(inv.r_preimage) : '';
let state: InvoiceState;
switch (inv.state) {
case 'SETTLED':
state = 'settled';
break;
case 'CANCELED':
state = 'canceled';
break;
case 'ACCEPTED':
state = 'accepted';
break;
default:
// lnrpc has no EXPIRED member; an unsettled invoice past its deadline is reported as OPEN.
state = expiresAt != null && expiresAt * 1000 < Date.now() ? 'expired' : 'open';
}
return {
paymentHash: inv.r_hash ? base64ToHex(inv.r_hash) : '',
bolt11: inv.payment_request ?? '',
amountMsat: inv.value_msat && inv.value_msat !== '0' ? toMsat(inv.value_msat) : null,
amountPaidMsat: inv.amt_paid_msat && inv.amt_paid_msat !== '0' ? toMsat(inv.amt_paid_msat) : null,
memo: inv.memo ? inv.memo : null,
state,
createdAt,
expiresAt,
settledAt: settledAt > 0 ? settledAt : null,
// LND ships an all-zero r_preimage for anything it has not settled — that is a placeholder, not a
// secret, and passing it on would let a caller believe it can claim the HTLC.
preimage: preimageHex && preimageHex !== ZERO_PREIMAGE_HEX ? preimageHex : null,
isKeysend: inv.is_keysend === true,
isAmp: inv.is_amp === true,
};
}
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
// reversed=true walks the add_index backwards, i.e. newest first.
const res = await this.get<{ invoices?: LndInvoice[] }>('/v1/invoices', {
reversed: true,
num_max_invoices: opts?.limit ?? 500,
});
return (res.invoices ?? []).map((inv) => this.toInvoice(inv));
}
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
const res = await this.post<LndAddInvoiceResponse>('/v1/invoices', {
memo: req.memo,
value_msat: req.amountMsat,
expiry: req.expirySeconds != null ? String(req.expirySeconds) : undefined,
is_amp: req.isAmp,
private: req.private,
r_preimage: req.preimage ? hexToBase64(req.preimage) : undefined,
});
const paymentHash = res.r_hash ? base64ToHex(res.r_hash) : '';
// AddInvoiceResponse carries only the hash, the request string and the payment address — no
// timestamps and no state — so read the invoice back to answer with a complete record.
const stored = paymentHash ? await this.lookupInvoice(paymentHash).catch(() => null) : null;
if (stored) return stored;
const now = Math.floor(Date.now() / 1000);
return {
paymentHash,
bolt11: res.payment_request ?? '',
amountMsat: req.amountMsat ?? null,
amountPaidMsat: null,
memo: req.memo ?? null,
state: 'open',
createdAt: now,
expiresAt: req.expirySeconds != null ? now + req.expirySeconds : null,
settledAt: null,
preimage: null,
isKeysend: false,
isAmp: req.isAmp === true,
};
}
override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
try {
// The path segment is `r_hash_str` — hex, not the base64 used by the body fields.
const inv = await this.get<LndInvoice>(`/v1/invoice/${paymentHash}`);
return this.toInvoice(inv);
} catch (err) {
if (err instanceof BackendError && err.status === 404) return null;
throw err;
}
}
override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
const res = await this.get<LndPayReq>(`/v1/payreq/${encodeURIComponent(bolt11)}`);
return {
bolt11,
// PayReq.payment_hash is a proto `string` (already hex), unlike Invoice.r_hash which is `bytes`.
paymentHash: res.payment_hash ?? '',
amountMsat: res.num_msat && res.num_msat !== '0' ? toMsat(res.num_msat) : null,
description: res.description ? res.description : null,
destination: res.destination ?? '',
timestamp: toSats(res.timestamp),
expiry: toSats(res.expiry),
cltvExpiry: res.cltv_expiry != null ? toSats(res.cltv_expiry) : null,
routeHints: (res.route_hints ?? []).length > 0,
features: Object.values(res.features ?? {})
.map((feature) => feature.name)
.filter((name): name is string => !!name),
};
}
private toPayment(payment: LndPayment): Payment {
const preimage = payment.payment_preimage ?? '';
// creation_date (seconds) was deprecated in favour of creation_time_ns; accept either.
const createdAt = payment.creation_date
? toSats(payment.creation_date)
: Math.floor(toSats(payment.creation_time_ns) / 1e9);
const hops = payment.htlcs?.[0]?.route?.hops ?? [];
return {
// ListPayments renders payment_hash/payment_preimage as proto `string`s — already hex.
paymentHash: payment.payment_hash ?? '',
preimage: preimage && preimage !== ZERO_PREIMAGE_HEX ? preimage : null,
amountMsat: toMsat(payment.value_msat),
feeMsat: toMsat(payment.fee_msat),
status: toPaymentStatus(payment.status),
createdAt,
// lnrpc.Payment has no destination field; the last hop of the first attempt's route is the payee.
destination: hops[hops.length - 1]?.pub_key ?? null,
// Recovering the memo would mean decoding payment_request on every row.
memo: null,
failureReason:
payment.failure_reason && payment.failure_reason !== 'FAILURE_REASON_NONE' ? payment.failure_reason : null,
};
}
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
const res = await this.get<{ payments?: LndPayment[] }>('/v1/payments', {
include_incomplete: true,
max_payments: opts?.limit ?? 500,
reversed: true,
});
return (res.payments ?? []).map((payment) => this.toPayment(payment));
}
override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
if (req.feeLimitMsat && req.feeLimitPercent != null) {
throw new BackendError('feeLimitMsat and feeLimitPercent are mutually exclusive', 400);
}
const timeoutSeconds = req.timeoutSeconds ?? 60;
const body: Record<string, unknown> = {
payment_request: req.bolt11,
timeout_seconds: timeoutSeconds,
// Zeus sets this so a payment to one's own node is not rejected as a self-payment.
allow_self_payment: true,
// Collapses the stream to a single terminal frame — see `routerSend`.
no_inflight_updates: true,
};
if (req.amountMsat) body.amt_msat = req.amountMsat;
if (req.feeLimitMsat) {
body.fee_limit_msat = req.feeLimitMsat;
} else if (req.feeLimitPercent != null) {
// SendPaymentRequest has no percentage form (that is a CLN concept), so resolve the invoice amount
// and turn the percentage into the absolute cap LND wants.
const amountMsat = req.amountMsat ?? (await this.decodeInvoice(req.bolt11)).amountMsat;
if (!amountMsat) {
throw new BackendError('feeLimitPercent needs an amount: the invoice is zero-amount', 400);
}
const limit = (BigInt(amountMsat) * BigInt(Math.round(req.feeLimitPercent * 100))) / 10_000n;
body.fee_limit_msat = limit.toString();
}
// Give the HTTP call a little more room than LND's own deadline so the node, not the socket, decides.
const result = await this.routerSend(body, (timeoutSeconds + 10) * 1000);
return this.toPayment(result);
}
override async sendKeysend(req: KeysendRequest): Promise<Payment> {
// Keysend is a spontaneous payment: WE pick the preimage, hash it ourselves, and ship the preimage to
// the receiver in TLV 5482373484 so it can settle an HTLC it never issued an invoice for.
const preimage = randomBytes(32);
const paymentHash = createHash('sha256').update(preimage).digest();
const customRecords: Record<string, string> = {
[KEYSEND_PREIMAGE_RECORD]: preimage.toString('base64'),
};
if (req.message) customRecords[KEYSEND_MESSAGE_RECORD] = Buffer.from(req.message, 'utf8').toString('base64');
const timeoutSeconds = 60;
const body: Record<string, unknown> = {
dest: hexToBase64(req.destination),
amt_msat: req.amountMsat,
payment_hash: paymentHash.toString('base64'),
dest_custom_records: customRecords,
// Without a route hint there is no invoice to read a final CLTV delta from; 40 is LND's own default.
final_cltv_delta: 40,
timeout_seconds: timeoutSeconds,
allow_self_payment: true,
no_inflight_updates: true,
};
if (req.feeLimitMsat) body.fee_limit_msat = req.feeLimitMsat;
const result = await this.routerSend(body, (timeoutSeconds + 10) * 1000);
return this.toPayment(result);
}
// ── channels / peers ───────────────────────────────────────────────────────────────────────────
override async getChannels(): Promise<Channel[]> {
const [open, pending] = await Promise.all([
// peer_alias_lookup makes LND resolve each peer's graph alias for us (v0.15.1+); on older nodes the
// parameter is ignored and `peer_alias` simply comes back absent.
this.get<{ channels?: LndChannel[] }>('/v1/channels', { peer_alias_lookup: true }),
this.get<LndPendingChannels>('/v1/channels/pending'),
]);
const channels: Channel[] = (open.channels ?? []).map((chan) => ({
channelId: chan.chan_id ?? chan.channel_point ?? '',
channelPoint: chan.channel_point ?? null,
remotePubkey: chan.remote_pubkey ?? '',
remoteAlias: chan.peer_alias ? chan.peer_alias : null,
capacitySats: toSats(chan.capacity),
localBalanceSats: toSats(chan.local_balance),
remoteBalanceSats: toSats(chan.remote_balance),
active: chan.active === true,
private: chan.private === true,
status: 'open',
}));
const fromPending = (entries: { channel?: LndPendingChannel }[] | undefined, status: string): Channel[] =>
(entries ?? [])
.map((entry) => entry.channel)
.filter((chan): chan is LndPendingChannel => !!chan)
.map((chan) => ({
// A pending channel has no short channel id yet — the funding outpoint is its only identity.
channelId: chan.channel_point ?? '',
channelPoint: chan.channel_point ?? null,
remotePubkey: chan.remote_node_pub ?? '',
remoteAlias: null,
capacitySats: toSats(chan.capacity),
localBalanceSats: toSats(chan.local_balance),
remoteBalanceSats: toSats(chan.remote_balance),
active: false,
private: chan.private === true,
status,
}));
return [
...channels,
...fromPending(pending.pending_open_channels, 'pending-open'),
// "waiting close" is a cooperative close whose closing tx has not confirmed; force-closing is the
// unilateral path with a timelock still to run.
...fromPending(pending.waiting_close_channels, 'pending-close'),
...fromPending(pending.pending_force_closing_channels, 'force-closing'),
];
}
override async getPeers(): Promise<Peer[]> {
const res = await this.get<{ peers?: LndPeer[] }>('/v1/peers');
return (res.peers ?? []).map((peer) => ({
pubkey: peer.pub_key ?? '',
address: peer.address ?? '',
alias: null,
inbound: peer.inbound === true,
}));
}
// ── signing ────────────────────────────────────────────────────────────────────────────────────
override async signMessage(message: string): Promise<SignMessageResult> {
// lnrpc.SignMessageRequest.msg is `bytes`, so the payload is base64 even though it is plain text.
const res = await this.post<{ signature?: string }>('/v1/signmessage', {
msg: Buffer.from(message, 'utf8').toString('base64'),
});
if (!res.signature) throw new BackendError('lnd returned no signature', 502);
return { signature: res.signature };
}
override async verifyMessage(message: string, signature: string): Promise<VerifyMessageResult> {
const res = await this.post<{ valid?: boolean; pubkey?: string }>('/v1/verifymessage', {
msg: Buffer.from(message, 'utf8').toString('base64'),
signature,
});
return { valid: res.valid === true, pubkey: res.pubkey ?? null };
}
}
@@ -0,0 +1,601 @@
// 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<string, { name?: string }>;
};
/** `/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<string, string | number | boolean | undefined>;
};
// ── 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<Capability> = new Set<Capability>([
'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<string> | 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<string> {
const res = await httpJson<LndHubAuth & LndHubErrorBody>({
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<string> {
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<T>(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<T>(req: LndHubCall): Promise<T> {
const send = async (token: string): Promise<T> =>
this.unwrap(
await httpJson<T>({
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<NodeInfo> {
let info: LndHubGetInfo | null = null;
try {
info = await this.call<LndHubGetInfo>({ 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<Balances> {
const res = await this.call<LndHubBalance>({ path: '/balance' });
return {
// A custodial account has no on-chain balance of its own; deposits land in the lightning balance.
onchainConfirmed: 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<OnchainTx[]> {
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<unknown>({ 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<string> {
const res = await this.call<LndHubAddress[] | LndHubAddress>({ path: '/getbtc' });
if (Array.isArray(res)) return res[0]?.address ?? '';
return res?.address ?? '';
}
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
const res = await this.call<LndHubUserInvoice[]>({
path: '/getuserinvoices',
query: { limit: opts?.limit },
});
return (Array.isArray(res) ? res : []).map((inv) => this.toInvoice(inv));
}
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
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<LndHubUserInvoice>({
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<Invoice | null> {
const wanted = paymentHash.toLowerCase();
const res = await this.call<LndHubUserInvoice[]>({ 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<DecodedInvoice> {
const res = await this.call<LndHubDecoded>({ 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<Payment[]> {
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<Payment> {
const amountSats = req.amountMsat ? msatToSats(req.amountMsat, 'amountMsat') : undefined;
const res = await this.call<LndHubPayResult>({
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<LndHubTx[]> {
const res = await this.call<LndHubTx[]>({ 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,
};
}
}
+407
View File
@@ -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;
}
}
}
@@ -0,0 +1,732 @@
// The native on-chain wallet backend.
//
// This is the server-side replacement for Zeus's two embedded backends. EmbeddedLND
// (backends/EmbeddedLND.ts) and LdkNode (backends/LdkNode.ts) are thin JS shims over React Native
// native modules — `lndmobile`, `ldk-node-rn` — that bundle an actual node into the app process.
// Neither exists off a phone, so neither can be ported into a Bun sidecar. Instead this backend is a
// real wallet in its own right: it derives addresses from a BIP32 account xpub, reads the chain from an
// Esplora HTTP API (chain.ts), and builds/signs its own transactions with bitcoinjs-lib (psbt.ts).
//
// WATCH-ONLY WHILE LOCKED is the load-bearing design property. Everything a UI polls —
// getInfo / getBalances / getTransactions / getNewAddress / getUtxos / estimateFees — is derived from
// the account XPUB and public chain data, so it works with the wallet locked and no key material in
// memory. `signer.withRoot` is reached from exactly two methods, sendCoins and signMessage, and both
// fail fast with WalletLockedError before doing any work. Nothing else in this file touches the signer.
//
// There is no lightning here at all: the capability set omits every lightning flag, so invoices,
// payments, channels and peers all fall through to BaseBackend's 501.
import { HDKey } from '@scure/bip32';
import * as bitcoin from 'bitcoinjs-lib';
import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371';
import * as ecc from '@bitcoinerlab/secp256k1';
import type { EsploraAddress, EsploraChain, EsploraTx } from '../chain';
import {
buildPsbt,
coinTypeFor,
initEcc,
networkFor,
outputScriptFor,
scriptType,
selectCoins,
signAndFinalize,
type PsbtInputSource,
type PsbtOutputSpec,
type SpendableUtxo,
} from '../psbt';
import {
BackendError,
WalletLockedError,
type AddressType,
type Balances,
type BackendKind,
type BitcoinNetwork,
type Capability,
type FeeEstimates,
type NewAddressRequest,
type NodeInfo,
type OnchainTx,
type SendCoinsRequest,
type SendCoinsResult,
type SignMessageResult,
type Utxo,
type VerifyMessageResult,
} from '../types';
import { BaseBackend } from './base';
// ── the signer boundary ──────────────────────────────────────────────────────────────────────────
/**
* The wallet's key custodian, implemented by keys.ts and injected here.
*
* This backend never sees a seed, a mnemonic, a passphrase or a file path. It receives a root HDKey for
* the duration of one synchronous callback and nothing more, which is what keeps the watch-only path
* genuinely key-free rather than key-free by convention.
*
* `withRoot` MUST throw `WalletLockedError` when the wallet is locked, and MUST NOT be async — an async
* borrow would pin the root in memory across arbitrary awaits.
*/
export interface WalletSigner {
isUnlocked(): boolean;
withRoot<T>(fn: (root: HDKey) => T): T;
}
// ── extended key parsing ─────────────────────────────────────────────────────────────────────────
/**
* SLIP-132 version bytes. @scure/bip32 refuses an extended key whose version does not match the
* `versions` it was handed, so the prefix has to be recognised before the key can be imported. Wallets
* export the purpose-tagged forms (zpub for BIP84, ypub for BIP49, vpub/upub on testnet) as often as
* they export a plain xpub, and all of them are the same key with different four leading bytes.
*/
const XPUB_VERSIONS: Record<string, { public: number; private: number }> = {
xpub: { public: 0x0488b21e, private: 0x0488ade4 },
ypub: { public: 0x049d7cb2, private: 0x049d7878 },
zpub: { public: 0x04b24746, private: 0x04b2430c },
tpub: { public: 0x043587cf, private: 0x04358394 },
upub: { public: 0x044a5262, private: 0x044a4e28 },
vpub: { public: 0x045f1cf6, private: 0x045f18bc },
};
function parseAccountXpub(key: string): HDKey {
const prefix = key.slice(0, 4).toLowerCase();
const versions = XPUB_VERSIONS[prefix];
if (!versions) throw new BackendError(`unrecognised extended key prefix '${prefix}'`, 400, 'BAD_XPUB');
try {
return HDKey.fromExtendedKey(key, versions);
} catch (err) {
throw new BackendError(`could not parse the account extended key: ${String(err)}`, 400, 'BAD_XPUB');
}
}
// ── derivation ───────────────────────────────────────────────────────────────────────────────────
/** BIP44 purpose per script type: 44' legacy, 49' wrapped segwit, 84' native segwit, 86' taproot. */
const PURPOSE: Record<AddressType, number> = {
p2pkh: 44,
'p2sh-p2wpkh': 49,
p2wpkh: 84,
p2tr: 86,
};
/** Preference order when the caller does not name a script type. Native segwit first. */
const TYPE_PREFERENCE: readonly AddressType[] = ['p2wpkh', 'p2tr', 'p2sh-p2wpkh', 'p2pkh'];
/** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */
type ChainIndex = 0 | 1;
type Account = {
type: AddressType;
node: HDKey;
/** Account-level path from the wallet root, e.g. `m/84'/0'/0'`. */
basePath: string;
};
type AddressEntry = {
type: AddressType;
chain: ChainIndex;
index: number;
address: string;
/** Compressed 33-byte pubkey, hex. */
pubkeyHex: string;
scriptPubKeyHex: string;
/** Full path from the wallet root — what the signer derives with. */
path: string;
/** Path relative to the account xpub, which is what the public Utxo type carries. */
relPath: string;
};
type ScannedAddress = AddressEntry & {
confirmedSats: number;
unconfirmedSats: number;
txCount: number;
used: boolean;
};
type WalletScan = {
at: number;
tipHeight: number;
addresses: ScannedAddress[];
/** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. */
byScript: Map<string, ScannedAddress>;
};
// ── tuning ───────────────────────────────────────────────────────────────────────────────────────
/** BIP44's standard gap limit: 20 consecutive unused addresses ends the scan for a chain. */
const GAP_LIMIT = 20;
/** How long a discovery scan stays fresh. Short enough to feel live, long enough that a dashboard
* polling getInfo/getBalances/getTransactions together costs one scan rather than three. */
const SCAN_TTL_MS = 30_000;
/** Parallel Esplora requests. Public instances rate-limit, so this stays modest. */
const REQUEST_CONCURRENCY = 6;
/** Hard stop on a runaway scan — a misconfigured xpub against a busy chain must not loop forever. */
const MAX_SCAN_INDEX = 1_000;
const DEFAULT_TX_LIMIT = 100;
// ── the backend ──────────────────────────────────────────────────────────────────────────────────
export type OnchainBackendOptions = {
chain: EsploraChain;
network: BitcoinNetwork;
/**
* The account-level extended public key. A bare string is taken as the BIP84 (p2wpkh) account; pass a
* map to enable more than one script type, e.g. `{ p2wpkh: 'zpub…', p2tr: 'xpub…' }`.
*/
accountXpub: string | Partial<Record<AddressType, string>>;
signer: WalletSigner;
};
export class OnchainBackend extends BaseBackend {
readonly kind: BackendKind = 'onchain';
protected readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
'onchainReceive',
'onchainSend',
'coinControl',
'psbt',
'sweep',
'signMessage',
]);
private readonly chain: EsploraChain;
private readonly network: BitcoinNetwork;
private readonly btcNetwork: bitcoin.Network;
private readonly signer: WalletSigner;
private readonly accounts: Map<AddressType, Account>;
private readonly defaultType: AddressType;
/** Derivation is pure EC math over a fixed xpub, so every address is derived at most once. */
private readonly derived = new Map<string, AddressEntry>();
/** In-memory issuance high-water mark per `type:chain`, so consecutive getNewAddress calls advance.
* Deliberately not persisted: after a restart the first unused address is recomputed from the chain,
* which is correct as soon as an issued address has actually been paid. */
private readonly issued = new Map<string, number>();
private scanCache: WalletScan | null = null;
private scanInflight: Promise<WalletScan> | null = null;
constructor(opts: OnchainBackendOptions) {
super();
initEcc();
this.chain = opts.chain;
this.network = opts.network;
this.btcNetwork = networkFor(opts.network);
this.signer = opts.signer;
const coin = coinTypeFor(opts.network);
const raw = typeof opts.accountXpub === 'string' ? { p2wpkh: opts.accountXpub } : opts.accountXpub;
this.accounts = new Map();
for (const type of TYPE_PREFERENCE) {
const xpub = raw[type];
if (!xpub) continue;
this.accounts.set(type, {
type,
node: parseAccountXpub(xpub),
basePath: `m/${PURPOSE[type]}'/${coin}'/0'`,
});
}
const first = TYPE_PREFERENCE.find((t) => this.accounts.has(t));
if (!first) throw new BackendError('the on-chain backend needs at least one account xpub', 400, 'BAD_XPUB');
this.defaultType = first;
}
// ── derivation helpers ─────────────────────────────────────────────────────────────────────────
private accountFor(type: AddressType): Account {
const account = this.accounts.get(type);
if (!account) {
const have = [...this.accounts.keys()].join(', ') || 'none';
throw new BackendError(`no ${type} account is configured (have: ${have})`, 400, 'NO_ACCOUNT');
}
return account;
}
private derive(type: AddressType, chain: ChainIndex, index: number): AddressEntry {
const cacheKey = `${type}:${chain}:${index}`;
const hit = this.derived.get(cacheKey);
if (hit) return hit;
const account = this.accountFor(type);
const node = account.node.derive(`m/${chain}/${index}`);
const pub = node.publicKey;
if (!pub) throw new BackendError(`failed to derive ${type} ${chain}/${index}`, 500);
const pubkey = Buffer.from(pub);
const network = this.btcNetwork;
const payment =
type === 'p2wpkh'
? bitcoin.payments.p2wpkh({ pubkey, network })
: type === 'p2tr'
? bitcoin.payments.p2tr({ internalPubkey: toXOnly(pubkey), network })
: type === 'p2sh-p2wpkh'
? bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey, network }), network })
: bitcoin.payments.p2pkh({ pubkey, network });
if (!payment.address || !payment.output) throw new BackendError(`failed to build a ${type} address`, 500);
const entry: AddressEntry = {
type,
chain,
index,
address: payment.address,
pubkeyHex: pubkey.toString('hex'),
scriptPubKeyHex: payment.output.toString('hex'),
path: `${account.basePath}/${chain}/${index}`,
relPath: `${chain}/${index}`,
};
this.derived.set(cacheKey, entry);
return entry;
}
// ── discovery scan ─────────────────────────────────────────────────────────────────────────────
/**
* Gap-limit scan of every configured account across both chains, memoised for SCAN_TTL_MS. Concurrent
* callers share one in-flight scan rather than each starting their own — without that, the three
* queries a wallet screen fires on mount would triple the request count against Esplora.
*/
private async scan(): Promise<WalletScan> {
const fresh = this.scanCache;
if (fresh && Date.now() - fresh.at < SCAN_TTL_MS) return fresh;
if (this.scanInflight) return this.scanInflight;
const run = this.runScan().then(
(result) => {
this.scanCache = result;
this.scanInflight = null;
return result;
},
(err: unknown) => {
this.scanInflight = null;
throw err;
},
);
this.scanInflight = run;
return run;
}
/** Drop the cache after a send, so the spent coins disappear from the next read immediately. */
private invalidateScan(): void {
this.scanCache = null;
}
private async runScan(): Promise<WalletScan> {
const tipHeight = await this.chain.getTipHeight();
const addresses: ScannedAddress[] = [];
// A wallet holds an account xpub for all four BIP purposes, but a full gap-limit walk of every one
// costs 4 types x 2 chains x 20 addresses = 160 requests, which trips the rate limit on every public
// Esplora instance. Only the wallet's own script type is walked unconditionally; the others are
// probed at receive index 0 first (one request each) and walked only if that address has ever been
// used. A freshly generated seed therefore costs 43 requests instead of 160, while a seed recovered
// from a wallet that used a different script type is still found rather than silently reported empty.
for (const type of this.accounts.keys()) {
if (type !== this.defaultType && !(await this.hasHistory(type))) continue;
for (const chain of [0, 1] as ChainIndex[]) {
addresses.push(...(await this.scanChain(type, chain)));
}
}
const byScript = new Map<string, ScannedAddress>();
for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry);
return { at: Date.now(), tipHeight, addresses, byScript };
}
/**
* Has this script type ever been used at all? One request against receive index 0, which is the
* address any wallet hands out first — so a used account is essentially never missed, and an unused
* one costs a single call instead of a forty-address walk.
*/
private async hasHistory(type: AddressType): Promise<boolean> {
const stat = await this.chain.getAddress(this.derive(type, 0, 0).address);
return toScannedAddress(this.derive(type, 0, 0), stat).used;
}
/** Walk one (type, chain) pair a gap-limit window at a time until GAP_LIMIT consecutive misses. */
private async scanChain(type: AddressType, chain: ChainIndex): Promise<ScannedAddress[]> {
const found: ScannedAddress[] = [];
let index = 0;
let gap = 0;
while (gap < GAP_LIMIT && index < MAX_SCAN_INDEX) {
const window = Array.from({ length: GAP_LIMIT }, (_, i) => this.derive(type, chain, index + i));
const stats = await mapLimit(window, REQUEST_CONCURRENCY, (entry) => this.chain.getAddress(entry.address));
for (let i = 0; i < window.length; i++) {
const entry = window[i];
const stat = stats[i];
if (!entry || !stat) continue;
const scanned = toScannedAddress(entry, stat);
found.push(scanned);
gap = scanned.used ? 0 : gap + 1;
if (gap >= GAP_LIMIT) break;
}
index += window.length;
}
return found;
}
// ── watch-only reads (no key material required) ────────────────────────────────────────────────
async getInfo(): Promise<NodeInfo> {
// Deliberately does not force a scan: this is the cheapest liveness probe the wallet has.
const blockHeight = await this.chain.getTipHeight();
return {
kind: 'onchain',
// An Esplora-backed wallet has no node identity, no alias and no upstream version to report.
pubkey: null,
alias: null,
version: null,
network: this.network,
blockHeight,
synced: true,
};
}
override async getBalances(): Promise<Balances> {
const scan = await this.scan();
let confirmed = 0;
let unconfirmed = 0;
for (const entry of scan.addresses) {
confirmed += entry.confirmedSats;
unconfirmed += entry.unconfirmedSats;
}
return {
onchainConfirmed: confirmed,
onchainUnconfirmed: unconfirmed,
// No channels exist, and null is the contract's "this backend has no lightning" value.
lightningBalance: null,
lightningInbound: null,
};
}
override async estimateFees(): Promise<FeeEstimates> {
return this.chain.getFeeEstimates();
}
override async getUtxos(): Promise<Utxo[]> {
const scan = await this.scan();
const utxos = await this.collectUtxos(scan);
return utxos.map((u) => ({
txid: u.txid,
vout: u.vout,
amountSats: u.amountSats,
address: u.address,
addressType: u.addressType,
confirmations: u.confirmations,
// The public contract wants the path relative to the account xpub, not the absolute one the
// signer uses, so it is recomputed from the tail of the full path.
derivationPath: u.derivationPath.split('/').slice(-2).join('/'),
// Freezing is a policy layer above this backend; nothing on-chain marks a coin frozen.
frozen: false,
}));
}
override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
const type = req?.type ?? this.defaultType;
this.accountFor(type);
const entry = await this.nextUnused(type, 0, req?.peek === true);
return { address: entry.address, type };
}
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
const scan = await this.scan();
const limit = opts?.limit ?? DEFAULT_TX_LIMIT;
// Only addresses that have ever been touched can appear in history.
const touched = scan.addresses.filter((a) => a.used);
const pages = await mapLimit(touched, REQUEST_CONCURRENCY, (a) => this.chain.getAddressTxs(a.address));
const seen = new Map<string, EsploraTx>();
for (const page of pages) {
for (const tx of page) if (!seen.has(tx.txid)) seen.set(tx.txid, tx);
}
const txs = [...seen.values()].map((tx) => this.toOnchainTx(tx, scan));
txs.sort((a, b) => {
// Unconfirmed first (they have no height), then newest block, then newest timestamp.
const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER;
const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER;
if (ah !== bh) return bh - ah;
return (b.timestamp ?? 0) - (a.timestamp ?? 0);
});
return txs.slice(0, limit);
}
/** Score one Esplora transaction against the wallet's own scripts. */
private toOnchainTx(tx: EsploraTx, scan: WalletScan): OnchainTx {
let credit = 0;
let debit = 0;
const ours: string[] = [];
const theirs: string[] = [];
for (const out of tx.vout) {
const mine = scan.byScript.get(out.scriptpubkey);
if (mine) {
credit += out.value;
ours.push(mine.address);
} else if (out.scriptpubkey_address) {
theirs.push(out.scriptpubkey_address);
}
}
for (const input of tx.vin) {
const prevout = input.prevout;
if (prevout && scan.byScript.has(prevout.scriptpubkey)) debit += prevout.value;
}
const height = tx.status.confirmed ? (tx.status.block_height ?? null) : null;
const confirmations = height === null ? 0 : Math.max(0, scan.tipHeight - height + 1);
const amount = credit - debit;
return {
txid: tx.txid,
amount,
// The fee is only ours to report when we funded an input; for an incoming payment the sender
// paid it and attributing it to this wallet would be a lie.
feeSats: debit > 0 ? tx.fee : null,
blockHeight: height,
timestamp: tx.status.block_time ?? null,
confirmations,
// No label store in this backend; SendCoinsRequest.label is accepted and dropped.
label: null,
destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours,
// Fetching /tx/{txid}/hex per transaction would double the request count for a list view. The
// contract permits null, and the raw hex is fetched on demand where it is actually needed.
rawHex: null,
};
}
// ── spending (requires the root key) ───────────────────────────────────────────────────────────
override async sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult> {
// Fail before any network work rather than after building a PSBT we cannot sign.
if (!this.signer.isUnlocked()) throw new WalletLockedError();
if (!Number.isFinite(req.satPerVbyte) || req.satPerVbyte <= 0) {
throw new BackendError('satPerVbyte must be a positive number', 400, 'INVALID_FEE_RATE');
}
const recipientScript = outputScriptFor(req.address, this.btcNetwork);
const recipientType = scriptType(recipientScript);
const scan = await this.scan();
const spendable = await this.collectUtxos(scan);
const selection = selectCoins({
utxos: spendable,
targetSats: req.amountSats ?? 0,
sendAll: req.sendAll === true,
satPerVbyte: req.satPerVbyte,
recipientType,
changeType: this.defaultType,
outpoints: req.outpoints,
spendUnconfirmed: req.spendUnconfirmed,
});
// Legacy inputs commit to the whole previous transaction, so it has to be fetched; segwit inputs
// carry their own value and script in the PSBT and need nothing extra.
const inputs = await mapLimit<SpendableUtxo, PsbtInputSource>(selection.inputs, REQUEST_CONCURRENCY, async (u) => ({
txid: u.txid,
vout: u.vout,
amountSats: u.amountSats,
addressType: u.addressType,
scriptPubKeyHex: u.scriptPubKeyHex,
pubkeyHex: u.pubkeyHex,
derivationPath: u.derivationPath,
prevTxHex: u.addressType === 'p2pkh' ? await this.chain.getTxHex(u.txid) : undefined,
}));
const outputs: PsbtOutputSpec[] = [{ address: req.address, amountSats: selection.outputSats }];
if (selection.changeSats !== null) {
const change = await this.nextUnused(this.defaultType, 1, false);
outputs.push({ address: change.address, amountSats: selection.changeSats });
// Change-is-always-last is a well-known chain-analysis heuristic. One shuffle removes it.
if (Math.random() < 0.5) outputs.reverse();
}
const { psbt, inputPaths } = buildPsbt({
network: this.btcNetwork,
inputs,
outputs,
rbf: req.rbf,
});
// The only place a root key enters this file. Synchronous, so the key is not held across an await.
const signed = this.signer.withRoot((root) => signAndFinalize(psbt, root, inputPaths));
const txid = await this.chain.broadcast(signed.rawHex);
this.invalidateScan();
return { txid, feeSats: signed.feeSats, rawHex: signed.rawHex };
}
override async signMessage(message: string): Promise<SignMessageResult> {
if (!this.signer.isUnlocked()) throw new WalletLockedError();
// BIP137-style: the identity key is the first receive key of the default account, which is what
// every other wallet that signs with a derived key uses.
const entry = this.derive(this.defaultType, 0, 0);
const hash = bitcoinMessageHash(message, this.btcNetwork);
const signature = this.signer.withRoot((root) => {
const node = root.derive(entry.path.replace(/[hH]/g, "'"));
const priv = node.privateKey;
if (!priv) throw new BackendError('derived node has no private key', 500);
const { signature: sig, recoveryId } = ecc.signRecoverable(hash, priv);
// Header byte: 27 + recovery id, +4 because the key is compressed.
const header = Buffer.from([27 + recoveryId + 4]);
return Buffer.concat([header, Buffer.from(sig)]).toString('base64');
});
return { signature };
}
/**
* Verification needs no key material — only the xpub — so it stays available while locked.
*
* The contract takes no claimed address, and a recoverable signature *always* recovers some pubkey,
* so "did this recover" is not a meaningful answer: a tampered message would still report valid.
* `valid` therefore means "this wallet signed this message" — the recovered key is compared against
* the identity key `signMessage` uses. The recovered pubkey is returned either way, so a caller
* verifying a third party's signature can do its own comparison.
*/
override async verifyMessage(message: string, signature: string): Promise<VerifyMessageResult> {
const sig = Buffer.from(signature, 'base64');
if (sig.length !== 65) return { valid: false, pubkey: null };
const header = sig[0];
if (header === undefined || header < 27 || header > 42) return { valid: false, pubkey: null };
const recoveryId = ((header - 27) & 3) as 0 | 1 | 2 | 3;
const compressed = ((header - 27) & 4) !== 0;
const compact = sig.subarray(1);
const hash = bitcoinMessageHash(message, this.btcNetwork);
try {
const recovered = ecc.recover(hash, compact, recoveryId, compressed);
if (!recovered || !ecc.verify(hash, recovered, compact)) return { valid: false, pubkey: null };
const pubkey = Buffer.from(recovered).toString('hex');
return { valid: pubkey === this.derive(this.defaultType, 0, 0).pubkeyHex, pubkey };
} catch {
return { valid: false, pubkey: null };
}
}
// ── shared internals ───────────────────────────────────────────────────────────────────────────
/** Fetch UTXOs for every scanned address that still holds a balance. */
private async collectUtxos(scan: WalletScan): Promise<SpendableUtxo[]> {
// An address whose funded and spent counts match holds nothing; asking Esplora about it is a
// wasted round trip, and on a wallet with long history that is most of the address set.
const funded = scan.addresses.filter((a) => a.confirmedSats + a.unconfirmedSats > 0);
const sets = await mapLimit(funded, REQUEST_CONCURRENCY, async (entry) => {
const utxos = await this.chain.getAddressUtxos(entry.address);
return utxos.map<SpendableUtxo>((u) => ({
txid: u.txid,
vout: u.vout,
amountSats: u.value,
address: entry.address,
addressType: entry.type,
confirmations:
u.status.confirmed && u.status.block_height !== undefined
? Math.max(0, scan.tipHeight - u.status.block_height + 1)
: 0,
derivationPath: entry.path,
frozen: false,
scriptPubKeyHex: entry.scriptPubKeyHex,
pubkeyHex: entry.pubkeyHex,
}));
});
return sets.flat();
}
/**
* First address on a chain that the blockchain has never seen, at or beyond the in-memory issuance
* mark. `peek` reads without consuming; otherwise the mark advances so the next call hands out a
* different address even before the current one is paid.
*/
private async nextUnused(type: AddressType, chain: ChainIndex, peek: boolean): Promise<AddressEntry> {
const scan = await this.scan();
const key = `${type}:${chain}`;
const hint = this.issued.get(key) ?? 0;
const used = new Set<number>();
let highest = -1;
for (const entry of scan.addresses) {
if (entry.type !== type || entry.chain !== chain) continue;
highest = Math.max(highest, entry.index);
if (entry.used) used.add(entry.index);
}
let index = hint;
// Anything past the scanned window is unused by definition — the gap limit is what ended the scan.
while (index <= highest && used.has(index)) index++;
if (!peek) this.issued.set(key, index + 1);
return this.derive(type, chain, index);
}
}
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
function toScannedAddress(entry: AddressEntry, stat: EsploraAddress): ScannedAddress {
const chainBalance = stat.chain_stats.funded_txo_sum - stat.chain_stats.spent_txo_sum;
// The mempool delta is signed: an unconfirmed spend of a confirmed coin reads negative here, which is
// exactly what the Balances contract wants in onchainUnconfirmed.
const mempoolBalance = stat.mempool_stats.funded_txo_sum - stat.mempool_stats.spent_txo_sum;
const txCount = stat.chain_stats.tx_count + stat.mempool_stats.tx_count;
return {
...entry,
confirmedSats: chainBalance,
unconfirmedSats: mempoolBalance,
txCount,
used: txCount > 0,
};
}
/** Varint, for the length prefix in the Bitcoin signed-message preimage. */
function varint(n: number): Buffer {
if (n < 0xfd) return Buffer.from([n]);
if (n <= 0xffff) {
const b = Buffer.alloc(3);
b[0] = 0xfd;
b.writeUInt16LE(n, 1);
return b;
}
const b = Buffer.alloc(5);
b[0] = 0xfe;
b.writeUInt32LE(n, 1);
return b;
}
/** sha256d(messagePrefix || varint(len) || message) — the standard signed-message preimage. */
function bitcoinMessageHash(message: string, network: bitcoin.Network): Buffer {
const prefix = Buffer.isBuffer(network.messagePrefix)
? network.messagePrefix
: Buffer.from(network.messagePrefix, 'utf8');
const body = Buffer.from(message, 'utf8');
return bitcoin.crypto.hash256(Buffer.concat([prefix, varint(body.length), body]));
}
/** Bounded-concurrency map that preserves input order. Esplora is per-address, so scans fan out wide. */
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out = new Array<R>(items.length);
let cursor = 0;
const worker = async (): Promise<void> => {
for (;;) {
const i = cursor++;
if (i >= items.length) return;
const item = items[i];
if (item === undefined) continue;
out[i] = await fn(item);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
}
+301
View File
@@ -0,0 +1,301 @@
// Vectors: the BOLT 11 spec examples (lightning/bolts, 11-payment-encoding.md § Examples), plus the
// regtest and signet invoices Zeus uses in its own utils/Bolt11Utils.test.ts. Every spec invoice below is
// signed with priv_key e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734.
import { describe, expect, it } from 'bun:test';
import { clearBolt11Cache, decodeBolt11, decodeBolt11Invoice } from './bolt11';
import { BackendError } from './types';
// ── spec vectors ─────────────────────────────────────────────────────────────────────────────────
/** no amount, description "Please consider supporting this project" */
const NO_AMOUNT =
'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql';
/** 2500u, description "1 cup coffee", expiry 60 */
const COFFEE_250U =
'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh';
/** 2500u, UTF-8 description, expiry 60 */
const NONSENSE_250U =
'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr';
/** 20m, description_hash instead of a description */
const HASHED_20M =
'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44';
/** the same on testnet, with a P2PKH fallback address */
const TESTNET_20M_FALLBACK =
'lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8';
/** 20m with a fallback address and a two-hop route hint */
const MAINNET_20M_ROUTES =
'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0';
/** 9678785340p — a msat amount that is not a whole number of sats */
const PICO_9678785340P =
'lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz';
/** 25m advertising features 8, 14 and 99 */
const FEATURES_25M =
'lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2a25dxl5hrntdtn6zvydt7d66hyzsyhqs4wdynavys42xgl6sgx9c4g7me86a27t07mdtfry458rtjr0v92cnmswpsjscgt2vcse3sgpz3uapa';
/** the same invoice in the all-uppercase QR form */
const FEATURES_25M_UPPER =
'LNBC25M1PVJLUEZPP5QQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQYPQDQ5VDHKVEN9V5SXYETPDEESSP5ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYGS9Q5SQQQQQQQQQQQQQQQQSGQ2A25DXL5HRNTDTN6ZVYDT7D66HYZSYHQS4WDYNAVYS42XGL6SGX9C4G7ME86A27T07MDTFRY458RTJR0V92CNMSWPSJSCGT2VCSE3SGPZ3UAPA';
/** 10m carrying payment metadata 0x01fafaf0 */
const METADATA_10M =
'lnbc10m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdp9wpshjmt9de6zqmt9w3skgct5vysxjmnnd9jx2mq8q8a04uqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q2gqqqqqqsgq7hf8he7ecf7n4ffphs6awl9t6676rrclv9ckg3d3ncn7fct63p6s365duk5wrk202cfy3aj5xnnp5gs3vrdvruverwwq7yzhkf5a3xqpd05wjc';
/** a valid invoice whose signature is high-S */
const HIGH_S_SIGNATURE =
'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap2r09nt4ndd0unm3z9u5t48y6ucv4r5sg7lk98c77ctvjczkspk5qprc90gx';
/** a high-S signature that does not match the invoice's own `n` payee field */
const PAYEE_MISMATCH =
'lnbc25m1p70xwfzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaqnp4q0n326hr8v9zprg8gsvezcch06gfaqqhde2aj730yg0durunfhv66sp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsp5cfzp9ugllvk03rltd6hvndxj26ux6gcxc5azyxk060rj9tzghct5zvjlps76gx8wpq5yuu79688k8gnm2c0al6v608s96l0xzrrlqqwnzxmu';
/** bech32 checksum is invalid */
const BAD_CHECKSUM =
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt';
/** signature is not recoverable */
const UNRECOVERABLE_SIGNATURE =
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqwgt7mcn5yqw3yx0w94pswkpq6j9uh6xfqqqtsk4tnarugeektd4hg5975x9am52rz4qskukxdmjemg92vvqz8nvmsye63r5ykel43pgz7zq0g2';
/** string is too short */
const TOO_SHORT =
'lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh';
/** invalid amount multiplier */
const BAD_MULTIPLIER =
'lnbc2500x1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqrrzc4cvfue4zp3hggxp47ag7xnrlr8vgcmkjxk3j5jqethnumgkpqp23z9jclu3v0a7e0aruz366e9wqdykw6dxhdzcjjhldxq0w6wgqcnu43j';
/** invalid sub-millisatoshi precision */
const SUB_MSAT_PRECISION =
'lnbc2500000001p1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uv0m3umg28gclm5lqxtqqwk32uuk4k6673k6n5kfvx3d2h8s295fad45fdhmusm8sjudfhlf6dcsxmfvkeywmjdkxcp99202x';
// ── Zeus fixtures (utils/Bolt11Utils.test.ts) ────────────────────────────────────────────────────
/** regtest, 1230n = 123 sat, no expiry field */
const REGTEST_1230N =
'lnbcrt1230n1pj429x7pp57t97q4awqj3f529snr0pa6senk83sq5pp760qf5a4jzvd7xgwcksdqqcqzzsxqrrsssp57eqtv7vxr46arupna3w4ct0lkf2mqmz9wt044cwkks0rwlnhfr5s9qyyssqragwpwav7nfwv2xyuuamxxj4pnnpzv2hlw7j473repd3sq7st698ta9kmzmygt0w7tmncl56a6mnma0w7e5dlpqd0wy6x3v35rssldspjhh8p0';
/** signet, 567780n = 56778 sat */
const SIGNET_567780N =
'lntbs567780n1pnqr26ypp5c0wcrpzwxwqnwu2nld5q36dfc9yjrfdp87nn9d5y093jjncvqresdq0w3jhxar8v3n8xeccqzpuxqrrsssp5r94e3nwnw63gjaxc8wex38ufv2m6442vnrw49m7dad9jdum3tdsq9qyyssqk4dvvuk7zhhju8ztf7nfc2hzqq9gqtzuyc0ljz8nl93laxwv4869lt9fsxkxacje6eh4ur5ymg83hvakn4tfpzdu6fq49705sar7fxspga8qjp';
/** Every spec vector shares this payee, payment hash and timestamp. */
const SPEC_PAYEE = '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad';
const SPEC_PAYMENT_HASH = '0001020304050607080900010203040506070809000102030405060708090102';
const SPEC_TIMESTAMP = 1496314658;
// ── amounts ──────────────────────────────────────────────────────────────────────────────────────
describe('decodeBolt11 amounts', () => {
it('returns null for a zero-amount invoice', () => {
expect(decodeBolt11(NO_AMOUNT).amountMsat).toBeNull();
});
it('parses every multiplier as an exact msat string', () => {
// 20m = 0.02 BTC, 2500u = 0.0025 BTC, 1230n = 123 sat, 9678785340p = 967878534 msat.
expect(decodeBolt11(HASHED_20M).amountMsat).toBe('2000000000');
expect(decodeBolt11(COFFEE_250U).amountMsat).toBe('250000000');
expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000');
expect(decodeBolt11(PICO_9678785340P).amountMsat).toBe('967878534');
});
it('keeps amountMsat a string and never widens it to a number', () => {
const amount = decodeBolt11(PICO_9678785340P).amountMsat;
expect(typeof amount).toBe('string');
// A pico amount need not be a whole sat: the msat string is exact where `satoshis` cannot be.
expect(decodeBolt11Invoice(PICO_9678785340P).satoshis).toBeNull();
expect(decodeBolt11Invoice(REGTEST_1230N).satoshis).toBe(123);
});
it('rejects an unknown multiplier', () => {
expect(() => decodeBolt11(BAD_MULTIPLIER)).toThrow(BackendError);
});
it('rejects sub-millisatoshi precision instead of rounding it', () => {
expect(() => decodeBolt11(SUB_MSAT_PRECISION)).toThrow(BackendError);
});
});
// ── fields ───────────────────────────────────────────────────────────────────────────────────────
describe('decodeBolt11 fields', () => {
it('extracts the payment hash, destination and timestamp', () => {
const decoded = decodeBolt11(NO_AMOUNT);
expect(decoded.paymentHash).toBe(SPEC_PAYMENT_HASH);
expect(decoded.destination).toBe(SPEC_PAYEE);
expect(decoded.timestamp).toBe(SPEC_TIMESTAMP);
});
it('recovers a key from a high-S signature instead of rejecting it', () => {
// The spec's high-S vector is the donation invoice re-signed with s negated while keeping recovery
// flag 1, so it recovers a *different* (valid) key. What matters is that recovery runs at all —
// implementations that enforce low-S during recovery fail here.
expect(decodeBolt11(HIGH_S_SIGNATURE).destination).toMatch(/^0[23][0-9a-f]{64}$/);
expect(decodeBolt11(HIGH_S_SIGNATURE).destination).not.toBe(SPEC_PAYEE);
expect(decodeBolt11(HIGH_S_SIGNATURE).paymentHash).toBe(SPEC_PAYMENT_HASH);
});
it('returns the description for a `d` invoice and null for an `h` one', () => {
expect(decodeBolt11(NO_AMOUNT).description).toBe('Please consider supporting this project');
expect(decodeBolt11(COFFEE_250U).description).toBe('1 cup coffee');
expect(decodeBolt11(NONSENSE_250U).description).toBe('ナンセンス 1杯');
const hashed = decodeBolt11Invoice(HASHED_20M);
expect(decodeBolt11(HASHED_20M).description).toBeNull();
expect(hashed.descriptionHash).toBe('3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1');
});
it('defaults expiry to 3600 when there is no `x` field', () => {
expect(decodeBolt11(NO_AMOUNT).expiry).toBe(3600);
expect(decodeBolt11Invoice(NO_AMOUNT).expiry).toBeNull();
expect(decodeBolt11(REGTEST_1230N).expiry).toBe(3600);
});
it('reads an explicit expiry', () => {
expect(decodeBolt11(COFFEE_250U).expiry).toBe(60);
expect(decodeBolt11(PICO_9678785340P).expiry).toBe(604800);
expect(decodeBolt11Invoice(COFFEE_250U).expiresAt).toBe(SPEC_TIMESTAMP + 60);
});
it('expands the feature bitmap to the indices of the set bits', () => {
expect(decodeBolt11(NO_AMOUNT).features).toEqual(['8', '14']);
expect(decodeBolt11(FEATURES_25M).features).toEqual(['8', '14', '99']);
});
it('reports route hints and parses their hops', () => {
expect(decodeBolt11(NO_AMOUNT).routeHints).toBe(false);
const decoded = decodeBolt11Invoice(MAINNET_20M_ROUTES);
expect(decodeBolt11(MAINNET_20M_ROUTES).routeHints).toBe(true);
expect(decoded.routes).toHaveLength(1);
expect(decoded.routes[0]).toEqual([
{
pubkey: '029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255',
shortChannelId: '66051x263430x1800',
feeBaseMsat: 1,
feeProportionalMillionths: 20,
cltvExpiryDelta: 3,
},
{
pubkey: '039e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255',
shortChannelId: '197637x395016x2314',
feeBaseMsat: 2,
feeProportionalMillionths: 30,
cltvExpiryDelta: 4,
},
]);
});
it('parses payment secret, metadata and fallback addresses', () => {
expect(decodeBolt11Invoice(NO_AMOUNT).paymentSecret).toBe(
'1111111111111111111111111111111111111111111111111111111111111111',
);
expect(decodeBolt11Invoice(METADATA_10M).metadata).toBe('01fafaf0');
const fallback = decodeBolt11Invoice(TESTNET_20M_FALLBACK).fallbacks[0];
// 17 is the P2PKH marker; the program is the 20-byte hash160 of mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP.
expect(fallback?.version).toBe(17);
expect(fallback?.programHex).toHaveLength(40);
});
it('leaves cltvExpiry null when the invoice omits `c`', () => {
expect(decodeBolt11(NO_AMOUNT).cltvExpiry).toBeNull();
});
});
// ── networks ─────────────────────────────────────────────────────────────────────────────────────
describe('decodeBolt11 networks', () => {
it('resolves every bech32 network prefix', () => {
expect(decodeBolt11Invoice(NO_AMOUNT).network).toBe('bitcoin');
expect(decodeBolt11Invoice(TESTNET_20M_FALLBACK).network).toBe('testnet');
expect(decodeBolt11Invoice(SIGNET_567780N).network).toBe('signet');
expect(decodeBolt11Invoice(REGTEST_1230N).network).toBe('regtest');
});
it('parses the amount out of a multi-letter network prefix', () => {
// `lnbcrt1230n` and `lntbs567780n` are the cases where a greedy hrp match eats the amount.
expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000');
expect(decodeBolt11(SIGNET_567780N).amountMsat).toBe('56778000');
});
it('rejects an unknown network prefix', () => {
expect(() => decodeBolt11(NO_AMOUNT.replace(/^lnbc/, 'lnxyz'))).toThrow(BackendError);
});
});
// ── malformed input ──────────────────────────────────────────────────────────────────────────────
describe('decodeBolt11 rejections', () => {
const expectBadInvoice = (invoice: string) => {
let caught: unknown;
try {
decodeBolt11(invoice);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(BackendError);
expect((caught as BackendError).status).toBe(400);
expect((caught as BackendError).code).toBe('BAD_INVOICE');
};
it('throws BackendError(400, BAD_INVOICE) on a corrupted checksum', () => {
expectBadInvoice(BAD_CHECKSUM);
// The same invoice with one data character flipped.
expectBadInvoice(`${COFFEE_250U.slice(0, -1)}${COFFEE_250U.endsWith('h') ? 'w' : 'h'}`);
});
it('throws on a signature that cannot be recovered', () => {
expectBadInvoice(UNRECOVERABLE_SIGNATURE);
});
it('throws when the recovered key contradicts an explicit `n` payee field', () => {
expectBadInvoice(PAYEE_MISMATCH);
});
it('throws on a truncated payment request', () => {
expectBadInvoice(TOO_SHORT);
});
it('throws on input that is not a payment request at all', () => {
expectBadInvoice('');
expectBadInvoice('not an invoice');
expectBadInvoice(NO_AMOUNT.replace(/^ln/, ''));
});
it('throws on a mixed-case payment request but accepts the uppercase QR form', () => {
expectBadInvoice(`LNBC${NO_AMOUNT.slice(4)}`);
expect(decodeBolt11(FEATURES_25M_UPPER)).toEqual(decodeBolt11(FEATURES_25M));
// The contract's `bolt11` is always the canonical lowercase form.
expect(decodeBolt11(FEATURES_25M_UPPER).bolt11).toBe(FEATURES_25M);
});
});
// ── cache ────────────────────────────────────────────────────────────────────────────────────────
describe('the LRU cache', () => {
it('returns the same object for a repeat decode and survives a clear', () => {
clearBolt11Cache();
const first = decodeBolt11Invoice(COFFEE_250U);
expect(decodeBolt11Invoice(COFFEE_250U)).toBe(first);
expect(decodeBolt11Invoice(COFFEE_250U.toUpperCase())).toBe(first);
clearBolt11Cache();
const afterClear = decodeBolt11Invoice(COFFEE_250U);
expect(afterClear).not.toBe(first);
expect(afterClear).toEqual(first);
});
it('hands every decodeBolt11 caller its own object', () => {
const first = decodeBolt11(COFFEE_250U);
const second = decodeBolt11(COFFEE_250U);
expect(second).not.toBe(first);
expect(second).toEqual(first);
});
});
+516
View File
@@ -0,0 +1,516 @@
// BOLT11 invoice decoding, in-process — no node round-trip.
//
// Ported from Zeus's `utils/Bolt11Utils.ts`, which is itself derived from **light-bolt11-decoder**:
// Copyright (c) 2021 bitcoinjs contributors, fiatjaf. MIT licence.
// The bech32 walk, the human-readable-part amount grammar and the signature preimage construction are
// that upstream's work and are preserved verbatim in behaviour.
//
// INTENTIONAL DIFFERENCES from Zeus's port:
//
// 1. ERRORS. Zeus mixes `throw new Error(...)` with silent `undefined` returns (a bad multiplier sets
// `satoshis = null` and carries on; a `fromWordsUnsafe` failure yields `undefined`). Every failure
// here is a `BackendError(msg, 400, 'BAD_INVOICE')`, so routes.ts answers 400 rather than leaking a
// half-decoded invoice with null amounts into a payment flow.
// 2. NO bignumber.js. All amount arithmetic is BigInt: msat tops out at 2.1e18, which a double cannot
// hold, and BigInt does the same job exactly with one less dependency.
// 3. NO @noble/hashes. The single sha256 comes from `node:crypto`, which Bun provides natively.
// 4. EAGER signature recovery. Zeus installs lazy getters for `destination`/`signature` because its
// Activity list decodes hundreds of invoices to read only the timestamp. Here `DecodedInvoice`
// requires a non-null `destination` on every call, so laziness would never pay off; the LRU cache
// (which is what actually collapses repeat recoveries) is kept and sized for a server.
// 5. NO `sections` array. Zeus keeps light-bolt11-decoder's positional `{name, letters, value?: any}`
// list for back-compat. Nothing here consumes it, and it was the source of the `any`. Every tag is
// exposed as a named, typed field instead, with anything unrecognised in `unknownTags`.
// 6. MORE TAGS PARSED. Zeus leaves `r` (route hints), `9` (features) and `f` (fallback address) as
// unknown tags. All three are parsed, because the contract's `routeHints` and `features` fields need
// them. Feature bits are expanded to the indices of the set bits, matching how backends/clnrest.ts
// renders CLN's bitmap.
// 7. SPEC-CONFORMANT LENIENCY/STRICTNESS. Known tags whose `data_length` is wrong are skipped and
// unknown tags ignored (BOLT 11 requires both). An all-uppercase invoice — the QR form — is accepted
// and lowercased; a MIXED-case one is rejected, as the spec requires and Zeus's blanket
// `.toLowerCase()` did not. An explicit `n` payee is checked against the recovered key rather than
// trusted blindly, which is the check Zeus skips.
// 8. NO SIMNET. Zeus carries the btcd-only `sb` prefix; `BitcoinNetwork` has no member for it.
import { createHash } from 'node:crypto';
import { bech32 } from 'bech32';
import { recoverPublicKey } from '@noble/secp256k1';
import { BackendError, type BitcoinNetwork, type DecodedInvoice } from './types';
// ── shape ────────────────────────────────────────────────────────────────────────────────────────
export type Bolt11NetworkParams = {
/** The bech32 human-readable part that follows `ln`. */
readonly bech32: string;
readonly network: BitcoinNetwork;
readonly pubKeyHash: number;
readonly scriptHash: number;
readonly validWitnessVersions: readonly number[];
};
/** One hop of an `r` field. Multiple `r` fields each describe a separate route. */
export type Bolt11RouteHop = {
readonly pubkey: string;
/** `block x tx x output`, the standard short_channel_id rendering. */
readonly shortChannelId: string;
readonly feeBaseMsat: number;
readonly feeProportionalMillionths: number;
readonly cltvExpiryDelta: number;
};
/** An `f` field. Left as witness-version + program rather than re-encoded to an address string. */
export type Bolt11Fallback = {
/** 0-16 are segwit witness versions; 17 is P2PKH and 18 is P2SH. */
readonly version: number;
readonly programHex: string;
};
export type Bolt11UnknownTag = { readonly tagCode: number; readonly words: readonly number[] };
/** The full decode. `decodeBolt11` projects this onto the narrower `DecodedInvoice` contract. */
export type Bolt11Invoice = {
/** The invoice, lowercased. */
readonly paymentRequest: string;
/** The whole human-readable part, e.g. `lnbc2500u`. */
readonly prefix: string;
readonly network: BitcoinNetwork;
readonly networkParams: Bolt11NetworkParams;
readonly timestamp: number;
/** Decimal msat string, or null for a zero-amount ("any amount") invoice. Never a number. */
readonly millisatoshis: string | null;
/** Convenience only, and null whenever the amount is not a whole number of sats. */
readonly satoshis: number | null;
readonly paymentHash: string;
readonly paymentSecret: string | null;
readonly description: string | null;
readonly descriptionHash: string | null;
/** The explicit `n` payee field, when the invoice carries one. */
readonly payee: string | null;
/** `payee` when present, else recovered from the signature. 33-byte compressed pubkey, hex. */
readonly destination: string;
/** The raw `x` field; null when absent. `expirySeconds` applies the spec's 3600 default. */
readonly expiry: number | null;
readonly expirySeconds: number;
readonly expiresAt: number;
readonly cltvExpiry: number | null;
readonly metadata: string | null;
/** Indices of the set feature bits, as decimal strings. */
readonly featureBits: readonly string[];
readonly routes: readonly (readonly Bolt11RouteHop[])[];
readonly fallbacks: readonly Bolt11Fallback[];
/** 64-byte compact signature, hex. */
readonly signature: string;
readonly recoveryFlag: number;
readonly unknownTags: readonly Bolt11UnknownTag[];
};
// ── constants ────────────────────────────────────────────────────────────────────────────────────
/**
* Zeus caches 256 entries for a phone's Activity list. A server decodes the same invoice from several
* call sites (list → detail → pay), so the cache earns its keep the same way; 1024 entries of a few
* hundred bytes each is a rounding error against a Bun heap.
*/
const CACHE_LIMIT = 1024;
const cache = new Map<string, Bolt11Invoice>();
const MSAT_PER_BTC = 100_000_000_000n;
const MAX_MSAT = 2_100_000_000_000_000_000n;
const DIVISORS: Readonly<Record<string, bigint>> = {
m: 1_000n,
u: 1_000_000n,
n: 1_000_000_000n,
p: 1_000_000_000_000n,
};
const NETWORKS: readonly Bolt11NetworkParams[] = [
{ bech32: 'bc', network: 'bitcoin', pubKeyHash: 0x00, scriptHash: 0x05, validWitnessVersions: [0, 1] },
{ bech32: 'tb', network: 'testnet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
{ bech32: 'tbs', network: 'signet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
{ bech32: 'bcrt', network: 'regtest', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
];
const TAG_NAMES = {
1: 'payment_hash',
3: 'route_hint',
5: 'feature_bits',
6: 'expiry',
9: 'fallback_address',
13: 'description',
16: 'payment_secret',
19: 'payee',
23: 'description_hash',
24: 'min_final_cltv_expiry',
27: 'metadata',
} as const satisfies Record<number, string>;
/** BOLT 11: a reader MUST skip a `p`, `s`, `h` or `n` field whose data_length is not the fixed size. */
const FIXED_TAG_WORDS: Readonly<Record<number, number>> = { 1: 52, 16: 52, 23: 52, 19: 53 };
/** The signature occupies the last 104 words — 65 bytes: 64-byte compact sig + 1 recovery byte. */
const SIGNATURE_WORDS = 104;
const TIMESTAMP_WORDS = 7;
/** BOLT 11 default when no `x` field is present. */
const DEFAULT_EXPIRY_SECONDS = 3600;
const ROUTE_HOP_BYTES = 51;
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
function badInvoice(message: string): never {
throw new BackendError(`invalid bolt11 invoice: ${message}`, 400, 'BAD_INVOICE');
}
/** Big-endian base-32. BigInt because a hostile tag can claim far more than 53 bits. */
function wordsToInt(words: readonly number[]): number {
let value = 0n;
for (const word of words) value = value * 32n + BigInt(word);
if (value > BigInt(Number.MAX_SAFE_INTEGER)) badInvoice('a numeric field is out of range');
return Number(value);
}
function wordsToBytes(words: readonly number[]): Uint8Array {
const bytes = bech32.fromWordsUnsafe(words);
if (bytes == null) badInvoice('a field is not a whole number of bytes');
return Uint8Array.from(bytes);
}
const wordsToHex = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('hex');
const wordsToUtf8 = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('utf8');
/**
* The `9` field is a big-endian bit vector whose LAST bit is feature 0, so a word's position from the
* end fixes the base index. Rendered as decimal strings to match backends/clnrest.ts's `featureBits`.
*/
function wordsToFeatureBits(words: readonly number[]): string[] {
const bits: string[] = [];
for (let i = words.length - 1; i >= 0; i--) {
const word = words[i] ?? 0;
const base = (words.length - 1 - i) * 5;
for (let bit = 0; bit < 5; bit++) {
if (word & (1 << bit)) bits.push(String(base + bit));
}
}
return bits.sort((a, b) => Number(a) - Number(b));
}
function wordsToRoute(words: readonly number[]): Bolt11RouteHop[] {
const bytes = Buffer.from(wordsToBytes(words));
if (bytes.length === 0 || bytes.length % ROUTE_HOP_BYTES !== 0) badInvoice('a route hint is malformed');
const hops: Bolt11RouteHop[] = [];
for (let offset = 0; offset < bytes.length; offset += ROUTE_HOP_BYTES) {
const hop = bytes.subarray(offset, offset + ROUTE_HOP_BYTES);
const scid = hop.subarray(33, 41);
const block = scid.readUIntBE(0, 3);
const tx = scid.readUIntBE(3, 3);
const output = scid.readUInt16BE(6);
hops.push({
pubkey: hop.subarray(0, 33).toString('hex'),
shortChannelId: `${block}x${tx}x${output}`,
feeBaseMsat: hop.readUInt32BE(41),
feeProportionalMillionths: hop.readUInt32BE(45),
cltvExpiryDelta: hop.readUInt16BE(49),
});
}
return hops;
}
function wordsToFallback(words: readonly number[]): Bolt11Fallback | null {
const version = words[0];
// A fallback with no program is meaningless; the spec says to ignore an unparseable one.
if (version === undefined || words.length < 2) return null;
return { version, programHex: wordsToHex(words.slice(1)) };
}
/**
* The amount grammar from the human-readable part: `<digits><multiplier?>`, where the multiplier
* divides one bitcoin. Integer arithmetic throughout — a `p` amount that is not a multiple of 10 would
* be sub-millisatoshi and is invalid rather than rounded.
*/
function hrpToMsat(amount: string, multiplier: string): string {
if (!/^\d+$/.test(amount)) badInvoice(`"${amount}" is not a valid amount`);
if (multiplier && !(multiplier in DIVISORS)) badInvoice(`"${multiplier}" is not a valid amount multiplier`);
const value = BigInt(amount);
const divisor = multiplier ? DIVISORS[multiplier] : undefined;
const msat = divisor === undefined ? value * MSAT_PER_BTC : (value * MSAT_PER_BTC) / divisor;
if (multiplier === 'p' && value % 10n !== 0n) badInvoice('amount has sub-millisatoshi precision');
if (msat > MAX_MSAT) badInvoice('amount is outside of valid range');
return msat.toString();
}
/** 5-bit → 8-bit, right-padded with zeros: the preimage the signature covers. */
function convertBits(words: readonly number[]): Uint8Array {
let value = 0;
let bits = 0;
const result: number[] = [];
for (const word of words) {
value = (value << 5) | word;
bits += 5;
while (bits >= 8) {
bits -= 8;
result.push((value >> bits) & 0xff);
}
}
if (bits > 0) result.push((value << (8 - bits)) & 0xff);
return Uint8Array.from(result);
}
type Recovered = { destination: string; signature: string; recoveryFlag: number };
/** The signed message is sha256( utf8(prefix) || convertBits(dataWords) ). SEC1 recovery from there. */
function recoverPayee(prefix: string, sigWords: readonly number[], signedWords: readonly number[]): Recovered {
const sigBytes = wordsToBytes(sigWords);
const recoveryFlag = sigBytes[64];
if (sigBytes.length !== 65 || recoveryFlag === undefined || recoveryFlag > 3) {
badInvoice('signature is malformed');
}
const preimage = Buffer.concat([Buffer.from(prefix, 'utf8'), Buffer.from(convertBits(signedWords))]);
const hash = createHash('sha256').update(preimage).digest();
// @noble/secp256k1 v3 wants the recovery byte FIRST; bech32 carries it last.
const recoverable = Buffer.concat([Buffer.from([recoveryFlag]), Buffer.from(sigBytes.subarray(0, 64))]);
let pubkey: Uint8Array;
try {
pubkey = recoverPublicKey(recoverable, hash, { prehash: false });
} catch {
badInvoice('signature is not recoverable');
}
return {
destination: Buffer.from(pubkey).toString('hex'),
signature: Buffer.from(sigBytes.subarray(0, 64)).toString('hex'),
recoveryFlag,
};
}
/** `lnbc2500u` → the network params and the amount. */
function parsePrefix(prefix: string): { params: Bolt11NetworkParams; millisatoshis: string | null } {
// Non-greedy on the hrp so the trailing digits+multiplier win: `lnbcrt500u` → bcrt / 500 / u. A prefix
// with no amount ends in a letter the first pattern would eat, hence the amount-less second attempt.
let matches = /^ln(\S+?)(\d+)([a-z]?)$/.exec(prefix);
let amount = matches?.[2] ?? '';
let multiplier = matches?.[3] ?? '';
if (!matches) {
matches = /^ln(\S+)$/.exec(prefix);
amount = '';
multiplier = '';
}
const hrp = matches?.[1];
if (!hrp) badInvoice('not a lightning payment request');
const params = NETWORKS.find((candidate) => candidate.bech32 === hrp);
if (!params) badInvoice(`unknown network prefix "ln${hrp}"`);
return { params, millisatoshis: amount ? hrpToMsat(amount, multiplier) : null };
}
type Tags = {
paymentHash: string | null;
paymentSecret: string | null;
description: string | null;
descriptionHash: string | null;
payee: string | null;
expiry: number | null;
cltvExpiry: number | null;
metadata: string | null;
featureBits: string[];
routes: Bolt11RouteHop[][];
fallbacks: Bolt11Fallback[];
unknownTags: Bolt11UnknownTag[];
};
/** Walks the tagged fields. First occurrence of a field wins; unknown and wrong-length fields are skipped. */
function parseTags(dataWords: readonly number[]): Tags {
const tags: Tags = {
paymentHash: null,
paymentSecret: null,
description: null,
descriptionHash: null,
payee: null,
expiry: null,
cltvExpiry: null,
metadata: null,
featureBits: [],
routes: [],
fallbacks: [],
unknownTags: [],
};
let words = dataWords;
while (words.length > 0) {
const tagCode = words[0];
if (tagCode === undefined) break;
if (words.length < 3) badInvoice('a tagged field is truncated');
const length = wordsToInt(words.slice(1, 3));
words = words.slice(3);
if (length > words.length) badInvoice('a tagged field overruns the invoice');
const tagWords = words.slice(0, length);
words = words.slice(length);
const expected = FIXED_TAG_WORDS[tagCode];
if (expected !== undefined && length !== expected) continue;
const name: string | undefined = TAG_NAMES[tagCode as keyof typeof TAG_NAMES];
switch (name) {
case 'payment_hash':
tags.paymentHash ??= wordsToHex(tagWords);
break;
case 'payment_secret':
tags.paymentSecret ??= wordsToHex(tagWords);
break;
case 'description':
tags.description ??= wordsToUtf8(tagWords);
break;
case 'description_hash':
tags.descriptionHash ??= wordsToHex(tagWords);
break;
case 'payee':
tags.payee ??= wordsToHex(tagWords);
break;
case 'expiry':
tags.expiry ??= wordsToInt(tagWords);
break;
case 'min_final_cltv_expiry':
tags.cltvExpiry ??= wordsToInt(tagWords);
break;
case 'metadata':
tags.metadata ??= wordsToHex(tagWords);
break;
case 'feature_bits':
if (tags.featureBits.length === 0) tags.featureBits = wordsToFeatureBits(tagWords);
break;
case 'route_hint':
tags.routes.push(wordsToRoute(tagWords));
break;
case 'fallback_address': {
const fallback = wordsToFallback(tagWords);
if (fallback) tags.fallbacks.push(fallback);
break;
}
default:
tags.unknownTags.push({ tagCode, words: tagWords });
}
}
return tags;
}
// ── the decoder ──────────────────────────────────────────────────────────────────────────────────
/**
* Full decode, cached. The returned object is shared with every other caller for the same invoice and is
* typed readonly throughout — treat it as frozen.
*/
export function decodeBolt11Invoice(paymentRequest: string): Bolt11Invoice {
if (typeof paymentRequest !== 'string') badInvoice('expected a string');
const trimmed = paymentRequest.trim();
// The QR form is all-uppercase and legal; a mixed-case string is not (BOLT 11 / BIP-173).
if (/[a-z]/.test(trimmed) && /[A-Z]/.test(trimmed)) badInvoice('mixed-case payment request');
const normalized = trimmed.toLowerCase();
const cached = cache.get(normalized);
if (cached) {
// Re-insert to move to the MRU end of the Map's insertion order.
cache.delete(normalized);
cache.set(normalized, cached);
return cached;
}
if (!normalized.startsWith('ln')) badInvoice('not a lightning payment request');
let decoded: { prefix: string; words: number[] };
try {
decoded = bech32.decode(normalized, Number.MAX_SAFE_INTEGER);
} catch (err) {
badInvoice(err instanceof Error ? err.message.toLowerCase() : 'bech32 decode failed');
}
if (decoded.words.length < SIGNATURE_WORDS + TIMESTAMP_WORDS) badInvoice('payment request is too short');
const { params, millisatoshis } = parsePrefix(decoded.prefix);
const signedWords = decoded.words.slice(0, -SIGNATURE_WORDS);
const sigWords = decoded.words.slice(-SIGNATURE_WORDS);
const timestamp = wordsToInt(signedWords.slice(0, TIMESTAMP_WORDS));
const tags = parseTags(signedWords.slice(TIMESTAMP_WORDS));
if (!tags.paymentHash) badInvoice('no payment hash');
const recovered = recoverPayee(decoded.prefix, sigWords, signedWords);
// BOLT 11: when an `n` field is present a reader MUST still check the signature against it. A correct
// signer always recovers to its own key, so a mismatch means the invoice was tampered with.
if (tags.payee && tags.payee !== recovered.destination) badInvoice('signature does not match the payee');
const msat = millisatoshis === null ? null : BigInt(millisatoshis);
const expirySeconds = tags.expiry ?? DEFAULT_EXPIRY_SECONDS;
const invoice: Bolt11Invoice = {
paymentRequest: normalized,
prefix: decoded.prefix,
network: params.network,
networkParams: params,
timestamp,
millisatoshis,
satoshis: msat !== null && msat % 1000n === 0n ? Number(msat / 1000n) : null,
paymentHash: tags.paymentHash,
paymentSecret: tags.paymentSecret,
description: tags.description,
descriptionHash: tags.descriptionHash,
payee: tags.payee,
destination: tags.payee ?? recovered.destination,
expiry: tags.expiry,
expirySeconds,
expiresAt: timestamp + expirySeconds,
cltvExpiry: tags.cltvExpiry,
metadata: tags.metadata,
featureBits: tags.featureBits,
routes: tags.routes,
fallbacks: tags.fallbacks,
signature: recovered.signature,
recoveryFlag: recovered.recoveryFlag,
unknownTags: tags.unknownTags,
};
if (cache.size >= CACHE_LIMIT) {
const oldest = cache.keys().next();
if (!oldest.done) cache.delete(oldest.value);
}
cache.set(normalized, invoice);
return invoice;
}
/**
* The contract surface: a BOLT11 payment request as the backends report it. Throws
* `BackendError(…, 400, 'BAD_INVOICE')` for anything that does not decode.
*/
export function decodeBolt11(bolt11: string): DecodedInvoice {
const invoice = decodeBolt11Invoice(bolt11);
return {
bolt11: invoice.paymentRequest,
paymentHash: invoice.paymentHash,
amountMsat: invoice.millisatoshis,
description: invoice.description,
destination: invoice.destination,
timestamp: invoice.timestamp,
expiry: invoice.expirySeconds,
cltvExpiry: invoice.cltvExpiry,
routeHints: invoice.routes.length > 0,
features: [...invoice.featureBits],
};
}
/** Test seam: the LRU is process-global, so a test that measures recovery cost needs to reset it. */
export function clearBolt11Cache(): void {
cache.clear();
}
+281
View File
@@ -0,0 +1,281 @@
// Esplora REST client — the chain data source for the native on-chain wallet backend.
//
// Zeus's embedded backends (EmbeddedLND, LdkNode) get chain data from a node bound to a React Native
// native module. Nothing about that ports to a Bun server process, so the server-side wallet reads the
// chain over Esplora's HTTP API instead: mempool.space, blockstream.info, or a self-hosted esplora.
// Every call is a plain unauthenticated GET, which is why the backend can serve watch-only requests
// without ever touching key material.
//
// Two Esplora endpoints are NOT JSON and are handled explicitly below:
// GET /tx/{txid}/hex → the raw transaction as a bare hex string
// POST /tx → request body is bare hex, response body is the bare txid
// Everything else is JSON, and every wire shape it returns is typed in this file.
import { BackendError, type BitcoinNetwork, type FeeEstimates } from './types';
// ── configuration ────────────────────────────────────────────────────────────────────────────────
export type EsploraConfig = {
/** Base URL of the Esplora API root, e.g. `https://mempool.space/api`. Trailing slashes are trimmed. */
baseUrl: string;
network: BitcoinNetwork;
timeoutMs?: number;
};
const DEFAULT_TIMEOUT_MS = 20_000;
// ── wire shapes ──────────────────────────────────────────────────────────────────────────────────
/** Confirmation status shared by /tx, /address/{a}/txs and /address/{a}/utxo. */
export type EsploraTxStatus = {
confirmed: boolean;
block_height?: number;
block_hash?: string;
/** Unix seconds of the containing block. Absent while unconfirmed. */
block_time?: number;
};
/** Aggregate funded/spent counters. Esplora reports one set for the chain and one for the mempool. */
export type EsploraAddressStats = {
funded_txo_count: number;
funded_txo_sum: number;
spent_txo_count: number;
spent_txo_sum: number;
tx_count: number;
};
export type EsploraAddress = {
address: string;
chain_stats: EsploraAddressStats;
mempool_stats: EsploraAddressStats;
};
export type EsploraVout = {
/** scriptPubKey, hex. */
scriptpubkey: string;
scriptpubkey_asm: string;
/** Esplora's own classification: 'v0_p2wpkh' | 'v1_p2tr' | 'p2pkh' | 'p2sh' | 'op_return' | … */
scriptpubkey_type: string;
scriptpubkey_address?: string;
value: number;
};
export type EsploraVin = {
txid: string;
vout: number;
/** The output being spent. Null for coinbase inputs. */
prevout: EsploraVout | null;
scriptsig: string;
scriptsig_asm: string;
witness?: string[];
is_coinbase: boolean;
sequence: number;
inner_redeemscript_asm?: string;
inner_witnessscript_asm?: string;
};
export type EsploraTx = {
txid: string;
version: number;
locktime: number;
vin: EsploraVin[];
vout: EsploraVout[];
size: number;
weight: number;
/** Absolute fee in sats. Zero for coinbase. */
fee: number;
status: EsploraTxStatus;
};
export type EsploraUtxo = {
txid: string;
vout: number;
value: number;
status: EsploraTxStatus;
};
/** /fee-estimates: confirmation target (in blocks, as a string key) → sat/vB, as a float. */
export type EsploraFeeEstimates = Record<string, number>;
// ── fee mapping ──────────────────────────────────────────────────────────────────────────────────
// Esplora publishes an estimate per confirmation target; FeeEstimates in types.ts is mempool.space's
// five named tiers. These are the targets each tier maps onto.
const FEE_TARGETS = {
fastestFee: 1,
halfHourFee: 3,
hourFee: 6,
economyFee: 144,
minimumFee: 1008,
} as const satisfies Record<keyof FeeEstimates, number>;
/** Nothing below the default minimum relay fee will propagate, so 1 sat/vB is the hard floor. */
const MIN_SAT_VB = 1;
/**
* Pick the estimate for a confirmation target. Esplora only publishes some targets (1..25, then 144,
* 504, 1008), so when the exact key is missing we take the largest published target *at or below* the
* one asked for — a shorter target always quotes a higher rate, so this errs towards confirming.
*/
function pickForTarget(raw: EsploraFeeEstimates, target: number): number | null {
const exact = raw[String(target)];
if (typeof exact === 'number' && Number.isFinite(exact)) return exact;
let best: number | null = null;
let bestKey = -1;
for (const [key, rate] of Object.entries(raw)) {
const k = Number(key);
if (!Number.isFinite(k) || !Number.isFinite(rate)) continue;
if (k <= target && k > bestKey) {
bestKey = k;
best = rate;
}
}
return best;
}
/**
* Map Esplora's target→rate map onto the five named tiers. Exported separately from the client so the
* mapping can be unit-tested against a captured /fee-estimates payload with no network involved.
*/
export function mapFeeEstimates(raw: EsploraFeeEstimates): FeeEstimates {
const at = (target: number): number => Math.max(MIN_SAT_VB, Math.ceil(pickForTarget(raw, target) ?? MIN_SAT_VB));
const fastestFee = at(FEE_TARGETS.fastestFee);
// A tier must never quote more than a shorter-target tier: some esplora deployments serve a stale or
// partial map where a longer target reads higher, and a UI that shows "1 hour" above "fastest" is a
// bug report. Clamp each tier down to its faster neighbour.
const halfHourFee = Math.min(fastestFee, at(FEE_TARGETS.halfHourFee));
const hourFee = Math.min(halfHourFee, at(FEE_TARGETS.hourFee));
const economyFee = Math.min(hourFee, at(FEE_TARGETS.economyFee));
const minimumFee = Math.min(economyFee, at(FEE_TARGETS.minimumFee));
return { fastestFee, halfHourFee, hourFee, economyFee, minimumFee };
}
// ── the client ───────────────────────────────────────────────────────────────────────────────────
type RequestInitLite = {
method?: 'GET' | 'POST';
/** Sent verbatim as the body — Esplora's POST /tx takes bare hex, not JSON. */
body?: string;
contentType?: string;
};
export class EsploraChain {
readonly network: BitcoinNetwork;
private readonly baseUrl: string;
private readonly timeoutMs: number;
constructor(cfg: EsploraConfig) {
this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
this.network = cfg.network;
this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
}
/** Every request goes through here so timeouts and upstream errors become one BackendError shape. */
private async text(path: string, init: RequestInitLite = {}): Promise<string> {
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
let res: Response;
try {
res = await fetch(url, {
method: init.method ?? 'GET',
body: init.body,
headers: init.contentType ? { 'Content-Type': init.contentType } : undefined,
signal: controller.signal,
});
} catch (err) {
const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err);
throw new BackendError(`esplora request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
} finally {
clearTimeout(timer);
}
const body = await res.text();
if (!res.ok) {
// Esplora reports every failure as plain text: 'Transaction not found', 'sendrawtransaction RPC
// error: {"code":-26,...}'. There is no JSON envelope to unwrap, so the text is the message.
throw new BackendError(`esplora ${res.status} on ${path}: ${body.slice(0, 300)}`, res.status, 'ESPLORA_ERROR');
}
return body;
}
private async json<T>(path: string): Promise<T> {
const body = await this.text(path);
try {
return JSON.parse(body) as T;
} catch {
throw new BackendError(`esplora returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'ESPLORA_ERROR');
}
}
// ── endpoints ──────────────────────────────────────────────────────────────────────────────────
/** GET /address/{addr} — funded/spent counters for the chain and the mempool. */
getAddress(address: string): Promise<EsploraAddress> {
return this.json<EsploraAddress>(`/address/${encodeURIComponent(address)}`);
}
/**
* GET /address/{addr}/txs — the newest ~50 transactions (all mempool ones, then 25 confirmed).
* Pass `afterTxid` to page further back through confirmed history via /txs/chain/{last_seen_txid}.
*/
getAddressTxs(address: string, afterTxid?: string): Promise<EsploraTx[]> {
const addr = encodeURIComponent(address);
const path = afterTxid ? `/address/${addr}/txs/chain/${encodeURIComponent(afterTxid)}` : `/address/${addr}/txs`;
return this.json<EsploraTx[]>(path);
}
/** GET /address/{addr}/utxo — unspent outputs, confirmed and unconfirmed. */
getAddressUtxos(address: string): Promise<EsploraUtxo[]> {
return this.json<EsploraUtxo[]>(`/address/${encodeURIComponent(address)}/utxo`);
}
/** GET /tx/{txid}. */
getTx(txid: string): Promise<EsploraTx> {
return this.json<EsploraTx>(`/tx/${encodeURIComponent(txid)}`);
}
/** GET /tx/{txid}/hex — plain text, not JSON. Needed as `nonWitnessUtxo` for legacy p2pkh inputs. */
async getTxHex(txid: string): Promise<string> {
const hex = (await this.text(`/tx/${encodeURIComponent(txid)}/hex`)).trim();
if (!/^[0-9a-fA-F]+$/.test(hex)) {
throw new BackendError(`esplora returned a non-hex body for tx ${txid}`, 502, 'ESPLORA_ERROR');
}
return hex;
}
/** GET /blocks/tip/height — plain integer in the body. */
async getTipHeight(): Promise<number> {
const body = (await this.text('/blocks/tip/height')).trim();
const height = Number(body);
if (!Number.isInteger(height) || height < 0) {
throw new BackendError(`esplora returned a bad tip height: ${body.slice(0, 60)}`, 502, 'ESPLORA_ERROR');
}
return height;
}
/** GET /fee-estimates, folded onto the five named tiers. */
async getFeeEstimates(): Promise<FeeEstimates> {
return mapFeeEstimates(await this.json<EsploraFeeEstimates>('/fee-estimates'));
}
/**
* POST /tx — the body is the raw transaction hex with no JSON wrapper, and the 200 response body is
* the bare txid. A rejection comes back as a 400 with bitcoind's `sendrawtransaction RPC error` text,
* which `text()` above surfaces verbatim; that message is the only diagnosis a caller gets.
*/
async broadcast(rawHex: string): Promise<string> {
const txid = (await this.text('/tx', { method: 'POST', body: rawHex, contentType: 'text/plain' })).trim();
if (!/^[0-9a-fA-F]{64}$/.test(txid)) {
throw new BackendError(`broadcast did not return a txid: ${txid.slice(0, 200)}`, 502, 'ESPLORA_ERROR');
}
return txid;
}
}
/** Alias kept for call sites that read better as a client than as a chain source. */
export { EsploraChain as EsploraClient };
+200
View File
@@ -0,0 +1,200 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleOfficerRoute } from './routes';
import { getConfig, hasStoreKey } from './upstream';
import { lockAll } from './keys';
import { invalidateAll } from './resolve';
// The officer-wallet sidecar. A bitcoin wallet in the shape Zeus models one — several interchangeable
// backends behind one interface — but server-side, with the key material held here and nowhere else.
//
// WHY THIS PROCESS EXISTS SEPARATELY. Seeds and node credentials never enter the main Officer process.
// The platform is a thin auth proxy (src/servers/api/wallet/router.ts) that forwards to this port and
// holds nothing: no seed, no macaroon, no xpub. Compromising `officer` gets an attacker the ability to
// *call* this sidecar as the authenticated owner — it does not get them a key, and it cannot spend from a
// locked wallet, because a locked wallet has no key material in memory at all.
//
// BACKENDS (mirroring _references/zeus/backends/):
// onchain — self-custodial. BIP39 seed sealed under an owner passphrase (keys.ts), BIP84/86/49
// derivation, Esplora for chain data, bitcoinjs-lib for PSBT construction. This is the
// server analogue of Zeus's EmbeddedLND/LdkNode, which are native-module-bound and cannot
// be ported. Watch-only while locked; unlock only to sign.
// lnd — LND REST + macaroon (ported from backends/LND.ts)
// cln-rest — Core Lightning CLNRest + rune (ported from backends/CLNRest.ts)
// lndhub — custodial LNDHub/BlueWallet REST (ported from backends/LndHub.ts)
// nwc — Nostr Wallet Connect, NIP-47 (ported from backends/NostrWalletConnect.ts)
//
// When the owner runs their own node, it registers as an `lnd` or `cln-rest` wallet — no code change.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/wallet mount prefix before forwarding.
//
// GET /_health ours. Reports network, chain reachability, store key.
// GET /_officer/config network + esplora + unlock TTL + storeKeyConfigured
//
// GET /_officer/wallets list. Never includes secrets.
// POST /_officer/wallets create. Seeded wallets return the mnemonic ONCE,
// and only when the sidecar generated it.
// GET /_officer/wallets/active the currently selected wallet
// GET /_officer/wallets/:id one wallet
// PATCH /_officer/wallets/:id rename / defaultBip / config
// DELETE /_officer/wallets/:id {passphrase} required when the wallet holds a seed
// POST /_officer/wallets/:id/activate
//
// GET /_officer/wallets/:id/lock-state {hasSeed, unlocked, secondsRemaining}
// POST /_officer/wallets/:id/unlock {passphrase, ttlSec?}
// POST /_officer/wallets/:id/lock
// POST /_officer/wallets/:id/passphrase {oldPassphrase, newPassphrase}
// POST /_officer/wallets/:id/export-seed {passphrase} → the mnemonic. Logged as a warning.
//
// GET /_officer/wallets/:id/capabilities what this backend can actually do
// GET /_officer/wallets/:id/info node/chain identity + sync state
// GET /_officer/wallets/:id/balances on-chain confirmed/unconfirmed + lightning local/inbound
// GET /_officer/wallets/:id/transactions?limit on-chain history, owner labels overlaid
// GET /_officer/wallets/:id/address?peek fresh receive address
// GET /_officer/wallets/:id/utxos coin control view, freeze flags + labels overlaid
// POST /_officer/wallets/:id/utxos/freeze {outpoint, frozen, reason?}
// GET /_officer/wallets/:id/fees sat/vB estimates
// POST /_officer/wallets/:id/send on-chain spend. Requires an unlocked wallet.
// GET /_officer/wallets/:id/invoices?limit
// POST /_officer/wallets/:id/invoices create
// GET /_officer/wallets/:id/invoices/:hash lookup
// POST /_officer/wallets/:id/decode {bolt11}
// GET /_officer/wallets/:id/payments?limit
// POST /_officer/wallets/:id/pay {bolt11, amountMsat?, feeLimit…}
// POST /_officer/wallets/:id/keysend {destination, amountMsat}
// GET /_officer/wallets/:id/channels
// GET /_officer/wallets/:id/peers
// POST /_officer/wallets/:id/sign {message}
// POST /_officer/wallets/:id/verify {message, signature}
// GET|POST /_officer/wallets/:id/labels owner annotations for addresses and txids
//
// anything else 404
//
// Operations a backend cannot perform return 501 with code NOT_SUPPORTED, checked against its declared
// capability set before dispatch — never a confusing upstream error. A locked wallet returns 423
// WALLET_LOCKED from signing paths only; every read above keeps working.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Wallet payloads are small — PSBTs and invoices, never file uploads. A tight cap is free hardening.
maxRequestBodySize: 1 * 1024 * 1024,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === '/_health') {
const cfg = getConfig();
const started = Date.now();
try {
const res = await fetch(`${cfg.esploraUrl}/blocks/tip/height`, {
signal: AbortSignal.timeout(5_000),
});
const height = res.ok ? Number(await res.text()) : null;
return Response.json({
ok: res.ok,
network: cfg.network,
esplora: cfg.esploraUrl,
blockHeight: Number.isFinite(height) ? height : null,
// Surfaced because wallet creation is refused without it, and that failure would otherwise
// look like a bug rather than a missing config line.
storeKeyConfigured: hasStoreKey(),
ms: Date.now() - started,
});
} catch (err) {
return Response.json(
{
ok: false,
network: cfg.network,
esplora: cfg.esploraUrl,
error: String(err),
storeKeyConfigured: hasStoreKey(),
ms: Date.now() - started,
},
{ status: 502 },
);
}
}
if (url.pathname.startsWith('/_officer/')) {
try {
const res = await handleOfficerRoute(req, url);
if (res) return res;
return Response.json({ error: 'not found' }, { status: 404 });
} catch (err) {
// Method and path only. Bodies on this sidecar carry passphrases and mnemonics.
console.error(`[wallet] ${req.method} ${url.pathname} failed`, err instanceof Error ? err.message : err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
const cfg = getConfig();
console.log(`[wallet] listening on 127.0.0.1:${port} — network=${cfg.network} esplora=${cfg.esploraUrl}`);
if (!hasStoreKey()) {
console.warn('[wallet] VAULT_STORE_KEY is unset — wallet creation will be refused until it is configured');
}
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'wallet',
capabilities: ['wallet'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'wallet:server', port });
console.log(`[wallet] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[wallet] ${signal} received, locking all wallets and shutting down...`);
// Wipe key material before anything else. This is best-effort — see the caveat in keys.ts — but it
// costs nothing and closes the obvious window on a graceful restart.
lockAll();
invalidateAll();
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+196
View File
@@ -0,0 +1,196 @@
import { describe, test, expect } from 'bun:test';
import {
sealSeed,
generateSeed,
deriveAccountXpubs,
UnlockSession,
verifyPassphrase,
exportMnemonic,
changePassphrase,
} from './keys';
/**
* The seed custody core. This is the one file in the wallet where a bug is unrecoverable rather than
* merely wrong — a mistake here either loses the owner's coins or leaks the key that spends them, so the
* properties below are asserted rather than assumed.
*
* scrypt at N=2^17 makes each seal/open deliberately expensive (~1s), which is the entire point of the
* parameter and also why this file carries a raised timeout instead of a smaller N.
*/
// BIP39's canonical all-`abandon` vector, and the BIP32 root fingerprint it must produce.
const VECTOR = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const ROOT_FINGERPRINT = '73c5da0a';
const PASS = 'correct horse battery staple';
const SLOW = 60_000;
describe('sealSeed / deriveAccountXpubs', () => {
test(
'seals the vector and derives all four accounts',
async () => {
const env = await sealSeed(VECTOR, PASS);
const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
expect(env.v).toBe(1);
expect(fingerprint).toBe(ROOT_FINGERPRINT);
for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy();
},
SLOW,
);
test(
'the envelope never contains the plaintext mnemonic',
async () => {
const env = await sealSeed(VECTOR, PASS);
expect(JSON.stringify(env)).not.toContain('abandon');
},
SLOW,
);
test(
'sealing is non-deterministic but derivation is stable',
async () => {
// Fresh salt and nonce per seal, so two seals of one seed must not be byte-identical — otherwise a
// DB dump would reveal which wallets share a seed.
const [a, b] = [await sealSeed(VECTOR, PASS), await sealSeed(VECTOR, PASS)];
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b));
const [xa, xb] = [await deriveAccountXpubs(a, PASS, 'bitcoin'), await deriveAccountXpubs(b, PASS, 'bitcoin')];
expect(xb.xpubs[84]).toBe(xa.xpubs[84]);
},
SLOW,
);
test(
'rejects a mnemonic that fails its checksum',
async () => {
const bad = VECTOR.replace(/about$/, 'abandon');
await expect(sealSeed(bad, PASS)).rejects.toThrow(/BIP39/i);
},
SLOW,
);
test(
'rejects a passphrase under the minimum length',
async () => {
await expect(sealSeed(VECTOR, 'short')).rejects.toThrow(/8 characters/);
},
SLOW,
);
test(
'testnet derives a different account under the same root',
async () => {
const env = await sealSeed(VECTOR, PASS);
const [main, test_] = [
await deriveAccountXpubs(env, PASS, 'bitcoin'),
await deriveAccountXpubs(env, PASS, 'testnet'),
];
// Coin type 1' vs 0' — a different account, but the same seed, so the same root fingerprint.
expect(test_.xpubs[84]).not.toBe(main.xpubs[84]);
expect(test_.fingerprint).toBe(main.fingerprint);
},
SLOW,
);
});
describe('passphrase handling', () => {
test(
'accepts the right passphrase and rejects a wrong one',
async () => {
const env = await sealSeed(VECTOR, PASS);
expect(await verifyPassphrase(env, PASS)).toBe(true);
expect(await verifyPassphrase(env, 'not the passphrase')).toBe(false);
},
SLOW,
);
test(
'round-trips the mnemonic exactly',
async () => {
const env = await sealSeed(VECTOR, PASS);
expect(await exportMnemonic(env, PASS)).toBe(VECTOR);
},
SLOW,
);
test(
'rotation swaps the passphrase without disturbing the seed',
async () => {
const env = await sealSeed(VECTOR, PASS);
const next = 'an entirely different passphrase';
const rotated = await changePassphrase(env, PASS, next);
expect(await verifyPassphrase(rotated, next)).toBe(true);
expect(await verifyPassphrase(rotated, PASS)).toBe(false);
expect(await exportMnemonic(rotated, next)).toBe(VECTOR);
// The xpubs are stored alongside the envelope; if rotation changed them the wallet would silently
// start watching a different account and report a zero balance.
const [before, after] = [
await deriveAccountXpubs(env, PASS, 'bitcoin'),
await deriveAccountXpubs(rotated, next, 'bitcoin'),
];
expect(after.xpubs[84]).toBe(before.xpubs[84]);
expect(after.fingerprint).toBe(before.fingerprint);
},
SLOW,
);
});
describe('UnlockSession', () => {
test('holds no key material until unlocked', async () => {
const s = new UnlockSession(1);
expect(s.isUnlocked()).toBe(false);
expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
});
test(
'unlocks, derives the stored xpub, then relocks',
async () => {
const env = await sealSeed(VECTOR, PASS);
const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
const s = new UnlockSession(1);
await s.unlock(env, PASS, 60);
expect(s.isUnlocked()).toBe(true);
expect(s.secondsRemaining()).toBeGreaterThan(0);
expect(s.secondsRemaining()).toBeLessThanOrEqual(60);
// Proves the unlocked root is the same key the watch-only xpub came from.
expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]);
s.lock();
expect(s.isUnlocked()).toBe(false);
expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
},
SLOW,
);
test(
'a failed unlock leaves the session locked',
async () => {
const env = await sealSeed(VECTOR, PASS);
const s = new UnlockSession(1);
await expect(s.unlock(env, 'the wrong passphrase', 60)).rejects.toThrow();
expect(s.isUnlocked()).toBe(false);
},
SLOW,
);
});
describe('generateSeed', () => {
test(
'produces a distinct, valid mnemonic that survives a round trip',
async () => {
const mnemonic = generateSeed();
expect([12, 24]).toContain(mnemonic.split(' ').length);
expect(generateSeed()).not.toBe(mnemonic);
const env = await sealSeed(mnemonic, 'a sufficiently long passphrase');
expect(await exportMnemonic(env, 'a sufficiently long passphrase')).toBe(mnemonic);
},
SLOW,
);
});
+384
View File
@@ -0,0 +1,384 @@
import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
import { HDKey } from '@scure/bip32';
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import type { BitcoinNetwork } from './types';
import { BackendError, WalletLockedError } from './types';
// Seed custody for the wallet sidecar.
//
// THE THREAT MODEL, stated plainly, because it is the whole point of this file:
//
// Zeus stores its seed phrases as plaintext inside a JSON settings blob and leans entirely on the OS
// keychain (storage/index.ts + stores/SettingsStore.ts:32-67 — `seedPhrase?: string[]`). A phone has a
// secure enclave and a screen lock; a server has neither. So none of Zeus's key handling is reusable
// here and this is written from scratch.
//
// The seed is protected by TWO independent secrets, and an attacker needs BOTH:
//
// 1. An owner passphrase, which is never persisted anywhere. It derives a KEK via scrypt and that
// KEK wraps the random per-wallet DEK that actually encrypts the mnemonic.
// 2. VAULT_STORE_KEY from the environment, applied by queries/wallet.ts (../../databases/officer_db)
// over the already-encrypted envelope before it touches Postgres.
//
// Consequence: a stolen database dump is useless without .env, a stolen .env is useless without the
// passphrase, and a full server compromise still cannot spend while the wallet is locked, because a
// locked wallet holds no key material in memory at all.
//
// WATCH-ONLY WHILE LOCKED. The account xpubs are stored in the clear on purpose. Balances, history and
// receive addresses therefore work with the wallet locked and the passphrase nowhere on the machine —
// unlocking is required only to SIGN. This is the single most important property here: the wallet spends
// almost all of its life locked and still fully readable.
//
// WHAT THIS CANNOT DO. Once unlocked, the root key is in the Bun process's heap and Node gives no way to
// pin or reliably wipe it — GC may have copied it. `zeroize()` scrubs the buffers we own, which shrinks
// the window but does not close it. That is why the unlock TTL is short and defaults tight.
const scrypt = promisify(scryptCb) as (
password: string | Buffer,
salt: Buffer,
keylen: number,
options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;
// N=2^17 / r=8 / p=1 → ~128 MiB and ~1s per attempt on this class of hardware. Deliberately painful:
// this is the only thing standing between a leaked database + .env and the coins. Node's default maxmem
// is 32 MiB, which these parameters blow through, so it must be raised explicitly or scrypt throws.
const SCRYPT_N = 1 << 17;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
const KEY_LEN = 32;
/** Bump when the KDF parameters or envelope layout change, so old envelopes can be migrated on unlock. */
const ENVELOPE_VERSION = 1;
export type SeedEnvelope = {
v: number;
/** base64, 16 bytes — scrypt salt for the KEK. */
salt: string;
/** base64(iv[12] | tag[16] | ciphertext) — the DEK, wrapped under the passphrase-derived KEK. */
wrappedDek: string;
/** base64(iv[12] | tag[16] | ciphertext) — the BIP39 mnemonic, encrypted under the DEK. */
seed: string;
/** Whether a BIP39 passphrase (the "25th word") is part of this seed. Affects derivation, not secrecy. */
hasBip39Passphrase: boolean;
};
// ── AES-256-GCM primitives ───────────────────────────────────────────────────────────────────────
function gcmEncrypt(key: Buffer, plaintext: Buffer): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
}
function gcmDecrypt(key: Buffer, blob: string): Buffer {
const buf = Buffer.from(blob, 'base64');
if (buf.length < 29) throw new BackendError('malformed ciphertext', 500, 'BAD_ENVELOPE');
const decipher = createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12));
decipher.setAuthTag(buf.subarray(12, 28));
return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]);
}
/** Best-effort scrub of a buffer we own. See the caveat in the header comment. */
function zeroize(buf: Buffer | null): void {
if (buf) buf.fill(0);
}
// ── envelope construction ────────────────────────────────────────────────────────────────────────
async function deriveKek(passphrase: string, salt: Buffer): Promise<Buffer> {
return scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
maxmem: SCRYPT_MAXMEM,
});
}
/**
* Wrap a mnemonic into a sealed envelope. The mnemonic is validated against the BIP39 wordlist first —
* importing a typo'd phrase silently produces a valid-but-wrong wallet that shows a zero balance, which
* is a genuinely awful failure mode to debug.
*/
export async function sealSeed(
mnemonic: string,
ownerPassphrase: string,
bip39Passphrase?: string,
): Promise<SeedEnvelope> {
const normalized = mnemonic.trim().replace(/\s+/g, ' ').toLowerCase();
if (!validateMnemonic(normalized, wordlist)) {
throw new BackendError('not a valid BIP39 mnemonic (checksum or wordlist mismatch)', 400, 'BAD_MNEMONIC');
}
if (ownerPassphrase.length < 8) {
throw new BackendError('unlock passphrase must be at least 8 characters', 400, 'WEAK_PASSPHRASE');
}
const salt = randomBytes(16);
const kek = await deriveKek(ownerPassphrase, salt);
const dek = randomBytes(KEY_LEN);
// The BIP39 passphrase lives inside the encrypted payload, not beside it: it is as sensitive as the
// words themselves, since together they are the wallet.
const payload = Buffer.from(JSON.stringify({ mnemonic: normalized, bip39Passphrase: bip39Passphrase ?? '' }), 'utf8');
try {
return {
v: ENVELOPE_VERSION,
salt: salt.toString('base64'),
wrappedDek: gcmEncrypt(kek, dek),
seed: gcmEncrypt(dek, payload),
hasBip39Passphrase: Boolean(bip39Passphrase),
};
} finally {
zeroize(kek);
zeroize(dek);
zeroize(payload);
}
}
/** Generate a fresh 12- or 24-word mnemonic. 24 words (256 bits) is the default. */
export function generateSeed(words: 12 | 24 = 24): string {
return generateMnemonic(wordlist, words === 12 ? 128 : 256);
}
type OpenedSeed = { mnemonic: string; bip39Passphrase: string };
async function openEnvelope(env: SeedEnvelope, ownerPassphrase: string): Promise<OpenedSeed> {
if (env.v !== ENVELOPE_VERSION) {
throw new BackendError(`unsupported seed envelope version ${env.v}`, 500, 'BAD_ENVELOPE');
}
const kek = await deriveKek(ownerPassphrase, Buffer.from(env.salt, 'base64'));
let dek: Buffer | null = null;
try {
// A wrong passphrase fails here, as a GCM tag mismatch. That is the ONLY signal — we never store a
// verifier hash of the passphrase, because a verifier is an offline-crackable oracle.
dek = gcmDecrypt(kek, env.wrappedDek);
const payload = gcmDecrypt(dek, env.seed);
try {
return JSON.parse(payload.toString('utf8')) as OpenedSeed;
} finally {
zeroize(payload);
}
} catch (err) {
if (err instanceof BackendError) throw err;
throw new BackendError('incorrect passphrase', 401, 'BAD_PASSPHRASE');
} finally {
zeroize(kek);
zeroize(dek);
}
}
// ── derivation ───────────────────────────────────────────────────────────────────────────────────
export type Bip = 44 | 49 | 84 | 86;
/** Mainnet is coin type 0; every test network shares coin type 1 (BIP44). */
export function coinType(network: BitcoinNetwork): 0 | 1 {
return network === 'bitcoin' ? 0 : 1;
}
export function accountPath(bip: Bip, network: BitcoinNetwork, account = 0): string {
return `m/${bip}'/${coinType(network)}'/${account}'`;
}
function rootFromSeed(opened: OpenedSeed): HDKey {
const seed = Buffer.from(mnemonicToSeedSync(opened.mnemonic, opened.bip39Passphrase || undefined));
try {
return HDKey.fromMasterSeed(seed);
} finally {
zeroize(seed);
}
}
/**
* Derive the public account descriptors WITHOUT retaining any private material. Called once at import
* time; the returned xpubs are stored in the clear and are what makes watch-only-while-locked work.
*/
export async function deriveAccountXpubs(
env: SeedEnvelope,
ownerPassphrase: string,
network: BitcoinNetwork,
): Promise<{ fingerprint: string; xpubs: Record<Bip, string> }> {
const opened = await openEnvelope(env, ownerPassphrase);
const root = rootFromSeed(opened);
try {
const fingerprint = Buffer.from(new Uint8Array(new Uint32Array([root.fingerprint]).buffer))
.reverse()
.toString('hex');
const xpubs = {} as Record<Bip, string>;
for (const bip of [44, 49, 84, 86] as const) {
const node = root.derive(accountPath(bip, network));
xpubs[bip] = node.publicExtendedKey;
}
return { fingerprint, xpubs };
} finally {
root.wipePrivateData();
}
}
// ── the unlock session ───────────────────────────────────────────────────────────────────────────
const DEFAULT_TTL_SEC = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900);
// Brute-force resistance. scrypt already makes each guess cost ~1s and ~128 MiB, but an attacker with
// the DB and .env can grind offline anyway — this only protects the live endpoint. Backoff is per
// wallet id and resets on success.
const MAX_ATTEMPTS = 5;
const LOCKOUT_MS = 60_000;
type Attempts = { count: number; lockedUntil: number };
const attempts = new Map<number, Attempts>();
function checkLockout(walletId: number): void {
const a = attempts.get(walletId);
if (a && a.lockedUntil > Date.now()) {
const secs = Math.ceil((a.lockedUntil - Date.now()) / 1000);
throw new BackendError(`too many failed attempts, retry in ${secs}s`, 429, 'LOCKED_OUT');
}
}
function recordFailure(walletId: number): void {
const a = attempts.get(walletId) ?? { count: 0, lockedUntil: 0 };
a.count += 1;
if (a.count >= MAX_ATTEMPTS) {
a.lockedUntil = Date.now() + LOCKOUT_MS;
a.count = 0;
}
attempts.set(walletId, a);
}
/**
* A live, unlocked wallet. Holds the derived root key in memory and nothing else — the mnemonic itself
* is decrypted, converted to a root key, and dropped inside `unlock()`; it is never retained.
*
* Structurally satisfies the `Signer` interface that backends/onchain.ts consumes.
*/
export class UnlockSession {
private root: HDKey | null = null;
private timer: ReturnType<typeof setTimeout> | null = null;
private expiresAt = 0;
constructor(readonly walletId: number) {}
isUnlocked(): boolean {
return this.root !== null && Date.now() < this.expiresAt;
}
/** Seconds until auto-lock, or 0 when locked. For the UI's countdown. */
secondsRemaining(): number {
if (!this.isUnlocked()) return 0;
return Math.max(0, Math.ceil((this.expiresAt - Date.now()) / 1000));
}
async unlock(env: SeedEnvelope, ownerPassphrase: string, ttlSec = DEFAULT_TTL_SEC): Promise<void> {
checkLockout(this.walletId);
let opened: OpenedSeed;
try {
opened = await openEnvelope(env, ownerPassphrase);
} catch (err) {
recordFailure(this.walletId);
throw err;
}
attempts.delete(this.walletId);
this.lock(); // replace any existing session rather than leaking the old root
this.root = rootFromSeed(opened);
// Drop the words immediately — the root key is all any signing operation needs.
opened.mnemonic = '';
opened.bip39Passphrase = '';
this.arm(ttlSec);
}
private arm(ttlSec: number): void {
this.expiresAt = Date.now() + ttlSec * 1000;
this.timer = setTimeout(() => this.lock(), ttlSec * 1000);
// Don't hold the event loop open just to auto-lock; shutdown wipes memory anyway.
this.timer.unref?.();
}
lock(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.root?.wipePrivateData();
this.root = null;
this.expiresAt = 0;
}
/**
* Run `fn` with the root key. The ONLY way key material leaves this class, and it never escapes as a
* return value by construction — callers get a derived signature, not the key.
*
* Deliberately does NOT slide the TTL. An unlock is a bounded window the owner opened on purpose;
* refreshing it on use would let a compromised session stay open indefinitely by signing.
*/
withRoot<T>(fn: (root: HDKey) => T): T {
if (!this.isUnlocked() || !this.root) {
this.lock();
throw new WalletLockedError();
}
return fn(this.root);
}
}
// One session per wallet id, process-wide. The sidecar is a single process, so this map IS the unlock
// state — there is no cross-process sharing and deliberately no persistence: a sidecar restart relocks
// every wallet, which is the correct default.
const sessions = new Map<number, UnlockSession>();
export function sessionFor(walletId: number): UnlockSession {
let s = sessions.get(walletId);
if (!s) {
s = new UnlockSession(walletId);
sessions.set(walletId, s);
}
return s;
}
export function lockAll(): void {
for (const s of sessions.values()) s.lock();
}
/**
* Verify a passphrase without opening a session — used before destructive operations (seed export,
* wallet deletion) so they need a fresh confirmation even when the wallet is already unlocked.
*/
export async function verifyPassphrase(env: SeedEnvelope, ownerPassphrase: string): Promise<boolean> {
try {
await openEnvelope(env, ownerPassphrase);
return true;
} catch {
return false;
}
}
/**
* Reveal the mnemonic. The only function that returns raw seed words, and it exists solely so the owner
* can back up or migrate. Always requires the passphrase even if a session is open, and callers must
* gate it behind a fresh confirmation.
*/
export async function exportMnemonic(env: SeedEnvelope, ownerPassphrase: string): Promise<string> {
const opened = await openEnvelope(env, ownerPassphrase);
return opened.mnemonic;
}
/** Re-wrap an existing seed under a new passphrase. Requires the old one; never touches the DEK. */
export async function changePassphrase(
env: SeedEnvelope,
oldPassphrase: string,
newPassphrase: string,
): Promise<SeedEnvelope> {
const opened = await openEnvelope(env, oldPassphrase);
return sealSeed(opened.mnemonic, newPassphrase, opened.bip39Passphrase || undefined);
}
/** Constant-time compare for any confirmation token we hand out and take back. */
export function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
+565
View File
@@ -0,0 +1,565 @@
// Coin selection and PSBT construction/signing for the native on-chain wallet backend.
//
// This is the raw-transaction half of the wallet: given a set of UTXOs discovered from the chain (see
// chain.ts) and a BIP32 root supplied by the caller, it picks inputs, builds a bitcoinjs-lib Psbt,
// signs it, and hands back a finalised transaction ready for `EsploraClient.broadcast`.
//
// It is modelled on Zeus's SweepStore (stores/SweepStore.ts) — the same p2pkh / p2sh-p2wpkh / p2wpkh /
// p2tr input construction, the same `toXOnly` from bitcoinjs-lib/src/psbt/bip371 — with three
// differences that matter:
//
// 1. Zeus sweeps a single WIF key and estimates the fee by signing a throwaway PSBT. Here the fee is
// estimated analytically from per-script-type weights (see WEIGHTS below), because selection has
// to know the fee before it knows the input set, and signing to find out costs a key.
// 2. Zeus refuses p2tr sweeps (ZEUS-3276). Taproot key-path spends work here: the private key is
// BIP341-tweaked before signing and the signer exposes `signSchnorr`.
// 3. SECURITY: no function in this file reads a seed from disk, env or module state. `signAndFinalize`
// takes the root HDKey as a parameter and the caller — keys.ts, via the backend's signer interface
// — decides whether it is willing to hand one over. Broadcast deliberately lives in chain.ts so
// the root is never held across an `await`.
import type { HDKey } from '@scure/bip32';
import * as bitcoin from 'bitcoinjs-lib';
import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371';
import * as ecc from '@bitcoinerlab/secp256k1';
import { BackendError, type AddressType, type BitcoinNetwork } from './types';
// bitcoinjs-lib needs an ECC backend for anything taproot (x-only point tweaking). Module-level and
// idempotent — `initEcc()` is exported so a consumer that only touches address derivation can force it
// without depending on import order.
let eccReady = false;
export function initEcc(): void {
if (eccReady) return;
bitcoin.initEccLib(ecc);
eccReady = true;
}
initEcc();
// ── networks ─────────────────────────────────────────────────────────────────────────────────────
/**
* bitcoinjs-lib ships bitcoin/testnet/regtest only. Signet shares testnet's address parameters exactly
* (`tb` bech32 HRP, 0x6f p2pkh version, 0xc4 p2sh version) — only the genesis block and message magic
* differ, and neither is used for address encoding or signing — so signet maps onto testnet.
*/
export function networkFor(network: BitcoinNetwork): bitcoin.Network {
switch (network) {
case 'bitcoin':
return bitcoin.networks.bitcoin;
case 'regtest':
return bitcoin.networks.regtest;
case 'testnet':
case 'signet':
return bitcoin.networks.testnet;
}
}
/** BIP44 coin type: 0 for mainnet, 1 for every test chain (SLIP-44 "Testnet (all coins)"). */
export function coinTypeFor(network: BitcoinNetwork): 0 | 1 {
return network === 'bitcoin' ? 0 : 1;
}
// ── script classification ────────────────────────────────────────────────────────────────────────
/**
* Classify a scriptPubKey. Returns null for anything the wallet cannot own or size precisely (p2wsh,
* bare multisig, op_return, future witness versions).
*
* Note that a bare p2sh script is reported as 'p2sh-p2wpkh'. On the *output* side that is exact — every
* p2sh output is 23 bytes regardless of what redeems it — and on the input side the wallet only ever
* owns the wrapped-segwit form, so the conflation is safe in both directions.
*/
export function scriptType(script: Uint8Array): AddressType | null {
const b = script;
if (b.length === 25 && b[0] === 0x76 && b[1] === 0xa9 && b[2] === 0x14 && b[23] === 0x88 && b[24] === 0xac) {
return 'p2pkh';
}
if (b.length === 23 && b[0] === 0xa9 && b[1] === 0x14 && b[22] === 0x87) return 'p2sh-p2wpkh';
if (b.length === 22 && b[0] === 0x00 && b[1] === 0x14) return 'p2wpkh';
if (b.length === 34 && b[0] === 0x51 && b[1] === 0x20) return 'p2tr';
return null;
}
/** Same, from hex. */
export function scriptTypeFromHex(hex: string): AddressType | null {
return scriptType(Buffer.from(hex, 'hex'));
}
/** Classify a destination address by the script it encodes. Throws when the address is not valid here. */
export function addressScriptType(address: string, network: bitcoin.Network): AddressType | null {
return scriptType(outputScriptFor(address, network));
}
/** `bitcoin.address.toOutputScript` with the failure turned into a 400 instead of a bare Error. */
export function outputScriptFor(address: string, network: bitcoin.Network): Buffer {
try {
return bitcoin.address.toOutputScript(address, network);
} catch {
throw new BackendError(`invalid address for this network: ${address}`, 400, 'INVALID_ADDRESS');
}
}
// ── size and dust constants ──────────────────────────────────────────────────────────────────────
// Everything here is in *weight units* (4 wu = 1 vbyte) so segwit's quarter-vbyte discount survives the
// arithmetic instead of being rounded away per input. A flat 148-in/34-out estimate — the usual
// shortcut — overpays a p2wpkh spend by ~2.2x and a p2tr spend by ~2.6x, which at any real fee rate is
// money handed to a miner for nothing.
//
// Input weights, assuming a 72-byte low-R DER signature (71 is common, 72 is the worst case) and a
// 33-byte compressed pubkey:
//
// p2pkh 32 txid + 4 vout + 1 len + 107 scriptSig + 4 seq = 148 vB → 592 wu
// p2sh-p2wpkh 64 base vB (scriptSig = 23-byte redeemScript push) + 108 wu witness → 364 wu
// p2wpkh 41 base vB + 108 wu witness (2 items: 72-byte sig, 33-byte key) → 272 wu
// p2tr key-path 41 base vB + 66 wu witness (1 item: 64-byte schnorr sig) → 230 wu
//
// which come out at 148 / 91 / 68 / 57.5 vbytes — the same numbers Bitcoin Core assumes.
const INPUT_WEIGHT: Record<AddressType, number> = {
p2pkh: 592,
'p2sh-p2wpkh': 364,
p2wpkh: 272,
p2tr: 230,
};
// Output weights: 8-byte value + 1-byte script length + the script itself, all ×4.
// p2pkh 25B script → 34 vB, p2sh 23B → 32 vB, p2wpkh 22B → 31 vB, p2tr 34B → 43 vB.
const OUTPUT_WEIGHT: Record<AddressType, number> = {
p2pkh: 136,
'p2sh-p2wpkh': 128,
p2wpkh: 124,
p2tr: 172,
};
/** Unclassifiable output (p2wsh, future witness versions): charge the 43-vbyte p2tr/p2wsh size. */
const OUTPUT_WEIGHT_UNKNOWN = 172;
/** version(4) + locktime(4), ×4. The input/output count varints are added separately. */
const TX_OVERHEAD_WEIGHT = 32;
/** Segwit marker + flag: 1 byte each, but they live in the witness so they weigh 1 wu each. */
const SEGWIT_MARKER_WEIGHT = 2;
/**
* Bitcoin Core's dust threshold: 3 sat/vB (the default dustRelayFee) times the size of the output plus
* the size of the input that would eventually spend it — witness inputs are counted as a flat 67 vB.
* p2pkh (34+148)*3 = 546 · p2sh (32+148)*3 = 540 · p2wpkh (31+67)*3 = 294 · p2tr (43+67)*3 = 330
* A change output below its threshold is not created; the remainder is donated to the fee instead.
*/
export const DUST_THRESHOLD: Record<AddressType, number> = {
p2pkh: 546,
'p2sh-p2wpkh': 540,
p2wpkh: 294,
p2tr: 330,
};
/** Dust floor for an output we could not classify — use the most demanding value we know. */
const DUST_UNKNOWN = 546;
/** The network will not relay below this, so a caller asking for less gets bumped rather than stuck. */
const MIN_SAT_VB = 1;
function varintWeight(n: number): number {
if (n < 0xfd) return 4;
if (n <= 0xffff) return 12;
return 20;
}
function isSegwit(type: AddressType): boolean {
return type !== 'p2pkh';
}
export type VsizeParams = {
inputs: AddressType[];
/** Output script types, in order. `null` means "unclassifiable" and is charged 43 vbytes. */
outputs: (AddressType | null)[];
};
/**
* Virtual size of the transaction these inputs and outputs would produce, rounded up. Accurate to
* within one vbyte per input (the DER signature is occasionally 71 bytes rather than 72), always in the
* conservative direction, so the realised fee rate lands at or just above what was asked for.
*/
export function estimateVsize({ inputs, outputs }: VsizeParams): number {
let weight = TX_OVERHEAD_WEIGHT + varintWeight(inputs.length) + varintWeight(outputs.length);
if (inputs.some(isSegwit)) weight += SEGWIT_MARKER_WEIGHT;
for (const type of inputs) weight += INPUT_WEIGHT[type];
for (const type of outputs) weight += type === null ? OUTPUT_WEIGHT_UNKNOWN : OUTPUT_WEIGHT[type];
return Math.ceil(weight / 4);
}
// ── coin selection ───────────────────────────────────────────────────────────────────────────────
/**
* A UTXO the selector may spend. `derivationPath` is the FULL path from the wallet root
* (`m/84'/0'/0'/0/7`), not the account-relative form the public Utxo type carries, because it is used
* verbatim to derive the signing key.
*/
export type SpendableUtxo = {
txid: string;
vout: number;
amountSats: number;
address: string;
addressType: AddressType;
confirmations: number;
derivationPath: string;
frozen: boolean;
/** scriptPubKey of the output being spent, hex. */
scriptPubKeyHex: string;
/** Compressed 33-byte pubkey of the owning address, hex. */
pubkeyHex: string;
};
export type CoinSelectionParams = {
utxos: SpendableUtxo[];
/** Sats to pay the recipient. Ignored when `sendAll` is set. */
targetSats: number;
sendAll?: boolean;
satPerVbyte: number;
/** Script type of the recipient output; null when it is a script we cannot size exactly. */
recipientType: AddressType | null;
/** Script type the change output would use. */
changeType: AddressType;
/** Coin control: restrict the input set to these `txid:vout` outpoints. */
outpoints?: string[];
spendUnconfirmed?: boolean;
};
export type CoinSelection = {
inputs: SpendableUtxo[];
/** Sats actually paid to the recipient. Equals `targetSats` unless `sendAll`. */
outputSats: number;
/** Sats returned to the wallet, or null when no change output is created. */
changeSats: number | null;
feeSats: number;
/** The vsize the fee was computed from. */
vsize: number;
};
const sumSats = (utxos: SpendableUtxo[]): number => utxos.reduce((acc, u) => acc + u.amountSats, 0);
const outpointOf = (u: SpendableUtxo): string => `${u.txid}:${u.vout}`;
function dustFor(type: AddressType | null): number {
return type === null ? DUST_UNKNOWN : DUST_THRESHOLD[type];
}
/**
* Coin selection: a two-phase accumulative selector. Deliberately not branch-and-bound — BnB's payoff
* is finding changeless solutions in a large UTXO set, and it needs a waste metric plus a fallback
* anyway. Instead:
*
* Phase 1 — smallest sufficient single input. Walk the eligible UTXOs smallest-first and take the
* first one that can cover the payment on its own. One input is the cheapest possible
* transaction, and going smallest-first quietly consolidates the wallet's dust over time.
* Phase 2 — largest-first accumulation. Nothing single-handedly covers it, so add UTXOs
* largest-first (confirmed before unconfirmed) until the total covers payment + fee. Going
* largest-first minimises the input count, and each input costs real money.
*
* Both phases decide change the same way: prefer a change output, but if what is left after the
* with-change fee falls under the dust threshold, drop the change output and donate the remainder to
* the fee. The donation is bounded by roughly (one output's fee + one dust threshold), so it can never
* quietly become a large overpayment.
*/
export function selectCoins(params: CoinSelectionParams): CoinSelection {
const rate = Math.max(params.satPerVbyte, MIN_SAT_VB);
const allow = params.outpoints && params.outpoints.length > 0 ? new Set(params.outpoints) : null;
const eligible = params.utxos.filter((u) => {
// An explicit coin-control pick is authoritative: it overrides both the frozen flag and the
// confirmed-only default, because the user named this exact outpoint.
if (allow) return allow.has(outpointOf(u));
if (u.frozen) return false;
if (!params.spendUnconfirmed && u.confirmations < 1) return false;
return true;
});
if (eligible.length === 0) {
const why = allow ? 'none of the selected outpoints are spendable' : 'no spendable UTXOs';
throw new BackendError(why, 400, 'INSUFFICIENT_FUNDS');
}
if (params.sendAll) return sweepAll(eligible, rate, params.recipientType);
if (!Number.isInteger(params.targetSats) || params.targetSats <= 0) {
throw new BackendError('amountSats must be a positive integer', 400, 'INVALID_AMOUNT');
}
const attempt = (inputs: SpendableUtxo[]): CoinSelection | null => {
const total = sumSats(inputs);
const types = inputs.map((u) => u.addressType);
const withChange = estimateVsize({ inputs: types, outputs: [params.recipientType, params.changeType] });
const noChange = estimateVsize({ inputs: types, outputs: [params.recipientType] });
const feeWithChange = Math.ceil(withChange * rate);
const feeNoChange = Math.ceil(noChange * rate);
const change = total - params.targetSats - feeWithChange;
if (change >= DUST_THRESHOLD[params.changeType]) {
return {
inputs: [...inputs],
outputSats: params.targetSats,
changeSats: change,
feeSats: feeWithChange,
vsize: withChange,
};
}
if (total >= params.targetSats + feeNoChange) {
// Changeless: everything above the payment is fee. Smaller transaction, no dust output created.
return {
inputs: [...inputs],
outputSats: params.targetSats,
changeSats: null,
feeSats: total - params.targetSats,
vsize: noChange,
};
}
return null;
};
// Phase 1 — smallest sufficient single input.
const ascending = [...eligible].sort((a, b) => a.amountSats - b.amountSats);
for (const utxo of ascending) {
const single = attempt([utxo]);
if (single) return single;
}
// Phase 2 — largest-first accumulation, confirmed coins ahead of unconfirmed ones.
const ordered = [...eligible].sort((a, b) => {
const aPending = a.confirmations > 0 ? 0 : 1;
const bPending = b.confirmations > 0 ? 0 : 1;
if (aPending !== bPending) return aPending - bPending;
return b.amountSats - a.amountSats;
});
const chosen: SpendableUtxo[] = [];
for (const utxo of ordered) {
chosen.push(utxo);
const selection = attempt(chosen);
if (selection) return selection;
}
const available = sumSats(eligible);
throw new BackendError(
`insufficient funds: ${available} sat available, ${params.targetSats} sat requested plus fees`,
400,
'INSUFFICIENT_FUNDS',
);
}
/** sendAll: every eligible coin in, one output out, the fee taken off that output. */
function sweepAll(inputs: SpendableUtxo[], rate: number, recipientType: AddressType | null): CoinSelection {
const total = sumSats(inputs);
const vsize = estimateVsize({ inputs: inputs.map((u) => u.addressType), outputs: [recipientType] });
const feeSats = Math.ceil(vsize * rate);
const outputSats = total - feeSats;
if (outputSats < dustFor(recipientType)) {
throw new BackendError(
`sweep leaves ${outputSats} sat after a ${feeSats} sat fee, below the dust threshold`,
400,
'INSUFFICIENT_FUNDS',
);
}
return { inputs: [...inputs], outputSats, changeSats: null, feeSats, vsize };
}
// ── PSBT construction ────────────────────────────────────────────────────────────────────────────
/** RBF-signalling sequence (BIP125): any input below 0xfffffffe marks the whole transaction replaceable. */
const SEQUENCE_RBF = 0xfffffffd;
const SEQUENCE_FINAL = 0xffffffff;
export type PsbtInputSource = {
txid: string;
vout: number;
amountSats: number;
addressType: AddressType;
/** scriptPubKey being spent, hex. */
scriptPubKeyHex: string;
/** Compressed 33-byte pubkey of the owning address, hex. */
pubkeyHex: string;
/** Full BIP32 path from the wallet root — returned in `inputPaths` for `signAndFinalize`. */
derivationPath: string;
/** Whole previous transaction, hex. Required for p2pkh inputs, unused otherwise. */
prevTxHex?: string;
};
export type PsbtOutputSpec = { address: string; amountSats: number };
export type BuildPsbtParams = {
network: bitcoin.Network;
inputs: PsbtInputSource[];
outputs: PsbtOutputSpec[];
/** Signal RBF. Default true. */
rbf?: boolean;
locktime?: number;
};
export type BuiltPsbt = {
psbt: bitcoin.Psbt;
/** Derivation paths in input order — pass straight to `signAndFinalize`. */
inputPaths: string[];
};
/**
* Build an unsigned PSBT. Input construction is per script type, exactly as Zeus's SweepStore does it:
* legacy p2pkh needs the whole previous transaction (`nonWitnessUtxo`), segwit needs only the output
* being spent (`witnessUtxo`), wrapped segwit additionally needs the p2wpkh `redeemScript`, and taproot
* needs the x-only internal key so the signer can be matched against the output key.
*/
export function buildPsbt(params: BuildPsbtParams): BuiltPsbt {
initEcc();
const { network } = params;
if (params.inputs.length === 0) throw new BackendError('cannot build a PSBT with no inputs', 400);
if (params.outputs.length === 0) throw new BackendError('cannot build a PSBT with no outputs', 400);
const psbt = new bitcoin.Psbt({ network });
psbt.setVersion(2);
psbt.setLocktime(params.locktime ?? 0);
const sequence = params.rbf === false ? SEQUENCE_FINAL : SEQUENCE_RBF;
const inputPaths: string[] = [];
for (const source of params.inputs) {
const pubkey = Buffer.from(source.pubkeyHex, 'hex');
const script = Buffer.from(source.scriptPubKeyHex, 'hex');
const base = { hash: source.txid, index: source.vout, sequence };
switch (source.addressType) {
case 'p2pkh': {
if (!source.prevTxHex) {
throw new BackendError(`p2pkh input ${source.txid}:${source.vout} needs the previous tx hex`, 500);
}
psbt.addInput({ ...base, nonWitnessUtxo: Buffer.from(source.prevTxHex, 'hex') });
break;
}
case 'p2wpkh': {
psbt.addInput({ ...base, witnessUtxo: { script, value: source.amountSats } });
break;
}
case 'p2sh-p2wpkh': {
const redeem = bitcoin.payments.p2wpkh({ pubkey, network });
if (!redeem.output) throw new BackendError('failed to build the p2wpkh redeemScript', 500);
psbt.addInput({
...base,
witnessUtxo: { script, value: source.amountSats },
redeemScript: redeem.output,
});
break;
}
case 'p2tr': {
psbt.addInput({
...base,
witnessUtxo: { script, value: source.amountSats },
tapInternalKey: toXOnly(pubkey),
});
break;
}
}
inputPaths.push(source.derivationPath);
}
for (const output of params.outputs) {
// Validate against the configured network before adding — bitcoinjs would accept a foreign-network
// address encoded for a chain with the same prefixes and silently burn the funds.
outputScriptFor(output.address, network);
psbt.addOutput({ address: output.address, value: output.amountSats });
}
return { psbt, inputPaths };
}
// ── signing ──────────────────────────────────────────────────────────────────────────────────────
export type SignedTx = {
txid: string;
rawHex: string;
/** Realised vsize of the signed transaction — compare against the estimate to audit fee accuracy. */
vsize: number;
feeSats: number;
};
/**
* BIP341 key-path tweak. The output key is `P + H_TapTweak(P) * G` where P is the x-only internal key,
* so the private key has to be tweaked the same way before it can produce a matching schnorr signature.
* If the internal point has odd Y the scalar is negated first, since x-only keys are always even-Y.
*/
function tweakPrivateKey(priv: Uint8Array, pubkey: Buffer): Uint8Array {
const even = pubkey[0] === 0x02 ? priv : ecc.privateNegate(priv);
const tweak = bitcoin.crypto.taggedHash('TapTweak', toXOnly(pubkey));
const tweaked = ecc.privateAdd(even, tweak);
if (!tweaked) throw new BackendError('taproot tweak produced an invalid key', 500);
return tweaked;
}
function keyMaterial(node: HDKey): { priv: Uint8Array; pubkey: Buffer } {
const priv = node.privateKey;
const pub = node.publicKey;
if (!priv || !pub) throw new BackendError('derived node has no private key — cannot sign', 500);
return { priv, pubkey: Buffer.from(pub) };
}
/** ECDSA signer for p2pkh / p2sh-p2wpkh / p2wpkh inputs. */
function ecdsaSigner(node: HDKey): bitcoin.Signer {
const { priv, pubkey } = keyMaterial(node);
return {
publicKey: pubkey,
sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, priv)),
};
}
/**
* Schnorr signer for a taproot key-path spend. `publicKey` must be the TWEAKED key: bitcoinjs matches
* `toXOnly(signer.publicKey)` against the output key in the prevout (psbt.js getTaprootHashesForSig),
* and the untweaked internal key would simply not match, failing with "Can not sign for input".
*/
function taprootSigner(node: HDKey): bitcoin.Signer {
const { priv, pubkey } = keyMaterial(node);
const tweakedPriv = tweakPrivateKey(priv, pubkey);
const tweakedPub = ecc.pointFromScalar(tweakedPriv, true);
if (!tweakedPub) throw new BackendError('taproot tweak produced an invalid point', 500);
return {
publicKey: Buffer.from(tweakedPub),
sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, tweakedPriv)),
signSchnorr: (hash: Buffer) => Buffer.from(ecc.signSchnorr(hash, tweakedPriv)),
};
}
/** @scure/bip32 only accepts the apostrophe form of a hardened index. */
function normalizePath(path: string): string {
return path.replace(/[hH]/g, "'");
}
/**
* Sign every input from keys derived off `root`, finalise, and extract the transaction.
*
* `root` is a PARAMETER and is never cached, stored or closed over past this call: the caller holds the
* key material and this function borrows it for the duration of a synchronous signing pass. Nothing
* here awaits, so the root cannot be pinned alive by a pending network call.
*/
export function signAndFinalize(psbt: bitcoin.Psbt, root: HDKey, inputPaths: string[]): SignedTx {
initEcc();
const count = psbt.data.inputs.length;
if (inputPaths.length !== count) {
throw new BackendError(`expected ${count} derivation paths, got ${inputPaths.length}`, 500);
}
for (let i = 0; i < count; i++) {
const path = inputPaths[i];
const input = psbt.data.inputs[i];
if (path === undefined || input === undefined) {
throw new BackendError(`missing derivation path for input #${i}`, 500);
}
const node = root.derive(normalizePath(path));
// `tapInternalKey` is set only by the p2tr branch of buildPsbt, so it is the authoritative marker
// for "this input needs a schnorr signature over a tweaked key".
psbt.signInput(i, input.tapInternalKey ? taprootSigner(node) : ecdsaSigner(node));
}
psbt.finalizeAllInputs();
const feeSats = psbt.getFee();
const tx = psbt.extractTransaction();
return { txid: tx.getId(), rawHex: tx.toHex(), vsize: tx.virtualSize(), feeSats };
}
+119
View File
@@ -0,0 +1,119 @@
import { getWallet, getWalletSecrets, type WalletSummary } from 'officerdb';
import { EsploraChain } from './chain';
import { LndBackend } from './backends/lnd';
import { ClnRestBackend } from './backends/clnrest';
import { LndHubBackend } from './backends/lndhub';
import { NwcBackend } from './backends/nwc';
import { OnchainBackend } from './backends/onchain';
import { sessionFor } from './keys';
import { getConfig } from './upstream';
import { BackendError, BIP_ADDRESS_TYPE, type AddressType, type BitcoinNetwork, type WalletBackend } from './types';
// Turns a stored wallet row into a live backend instance. This is the one place that knows the mapping
// from `kind` to a class, and the one place node credentials are decrypted — getWalletSecrets() is
// called here and the plaintext never travels further than the constructor it is handed to.
//
// Instances are cached per wallet id. Backends hold connection state worth reusing (LNDHub's access
// token, NWC's relay socket, the on-chain gap-limit scan), and rebuilding one per request would both
// re-authenticate constantly and defeat the address-scan cache. The cache is invalidated whenever the
// wallet's config changes — see `invalidate()`, called from the update/delete routes.
type Cached = { backend: WalletBackend; configVersion: string };
const cache = new Map<number, Cached>();
export function invalidate(walletId: number): void {
cache.delete(walletId);
}
export function invalidateAll(): void {
cache.clear();
}
/** A stable fingerprint of the inputs a backend was built from, so a stale instance is detected. */
function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null): string {
return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config]);
}
export type Resolved = { wallet: WalletSummary; backend: WalletBackend };
export async function resolveBackend(userId: number, walletId: number): Promise<Resolved> {
const wallet = await getWallet(userId, walletId);
if (!wallet) throw new BackendError('wallet not found', 404, 'NOT_FOUND');
const secrets = await getWalletSecrets(userId, walletId);
const config = secrets?.config ?? null;
const version = versionOf(wallet, config);
const hit = cache.get(walletId);
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };
const backend = build(wallet, config);
cache.set(walletId, { backend, configVersion: version });
return { wallet, backend };
}
function required(config: Record<string, unknown> | null, key: string, kind: string): string {
const v = config?.[key];
if (typeof v !== 'string' || !v) {
throw new BackendError(`${kind} wallet is missing required config "${key}"`, 400, 'BAD_CONFIG');
}
return v;
}
function build(wallet: WalletSummary, config: Record<string, unknown> | null): WalletBackend {
const network = wallet.network as BitcoinNetwork;
switch (wallet.kind) {
case 'lnd':
return new LndBackend({
url: required(config, 'url', 'lnd'),
macaroonHex: required(config, 'macaroonHex', 'lnd'),
allowSelfSigned: config?.allowSelfSigned === true,
});
case 'cln-rest':
return new ClnRestBackend({
url: required(config, 'url', 'cln-rest'),
rune: required(config, 'rune', 'cln-rest'),
allowSelfSigned: config?.allowSelfSigned === true,
});
case 'lndhub':
return new LndHubBackend({
url: required(config, 'url', 'lndhub'),
login: required(config, 'login', 'lndhub'),
password: required(config, 'password', 'lndhub'),
});
case 'nwc':
return new NwcBackend({ connectionUri: required(config, 'connectionUri', 'nwc') });
case 'onchain': {
// Every xpub the wallet holds is handed over, not just the default BIP's. A seed derives all four
// accounts (keys.ts::deriveAccountXpubs), and coins can legitimately sit on any of them — a
// recovered seed may have been used with a p2tr wallet before, or received to a legacy address.
// Scanning only the default account would silently under-report the balance and leave those UTXOs
// unspendable. `defaultBip` then means only "which script type new receive addresses use".
const accountXpub: Partial<Record<AddressType, string>> = {};
for (const [bip, type] of Object.entries(BIP_ADDRESS_TYPE)) {
const xpub = wallet.xpubs?.[bip];
if (xpub) accountXpub[type] = xpub;
}
if (Object.keys(accountXpub).length === 0) {
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
}
const { esploraUrl } = getConfig();
return new OnchainBackend({
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
network,
accountXpub,
// The session is the signer. While locked it holds no key material, so watch-only reads below
// still work and only sendCoins/signMessage will throw WalletLockedError.
signer: sessionFor(wallet.id),
});
}
default:
throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG');
}
}
+536
View File
@@ -0,0 +1,536 @@
import {
listWallets,
getWallet,
getSealedSeed,
createWallet,
updateWallet,
setActiveWallet,
deleteWallet,
getActiveWallet,
getWalletLabels,
setWalletLabel,
getFrozenOutpoints,
setUtxoFrozen,
type WalletKind,
} from 'officerdb';
import { resolveBackend, invalidate } from './resolve';
import {
changePassphrase,
deriveAccountXpubs,
exportMnemonic,
generateSeed,
sealSeed,
sessionFor,
verifyPassphrase,
type SeedEnvelope,
} from './keys';
import { getConfig, hasStoreKey } from './upstream';
import {
asAddressType,
BackendError,
WalletLockedError,
BIP_ADDRESS_TYPE,
type BitcoinNetwork,
type Capability,
type Utxo,
type WalletBackend,
} from './types';
// The wallet sidecar's route surface. Every route is scoped to the authenticated owner via X-Officer-User,
// which the platform proxy injects (src/servers/api/wallet/router.ts) and which is trustworthy because the
// sidecar binds loopback only.
//
// Route ordering matters here: the seed/lock routes are matched BEFORE the generic wallet operations, so a
// wallet named "unlock" can never shadow the unlock endpoint.
//
// SECURITY NOTES that apply to this whole file:
// - No route ever returns a mnemonic, a sealed envelope, a node macaroon, or an unlock passphrase,
// except /export-seed, which exists for backup and demands the passphrase every single time.
// - Passphrases arrive in request bodies and are never logged. The catch-all handler at the bottom logs
// the method and path only, deliberately not the body.
// - Capability checks happen before dispatch so an unsupported operation is a clean 501.
export type OfficerContext = { req: Request; url: URL; userId: number };
function json(data: unknown, status = 200): Response {
return Response.json(data as Record<string, unknown>, { status });
}
function badRequest(message: string): Response {
return json({ error: message }, 400);
}
async function body<T>(req: Request): Promise<T> {
try {
return (await req.json()) as T;
} catch {
throw new BackendError('expected a JSON body', 400, 'BAD_BODY');
}
}
/** Guard a capability before dispatching, so callers get 501 rather than a confusing upstream error. */
function requireCap(backend: WalletBackend, cap: Capability, op: string): void {
if (!backend.supports(cap)) {
throw new BackendError(`this wallet does not support ${op}`, 501, 'NOT_SUPPORTED');
}
}
// ── entry point ──────────────────────────────────────────────────────────────────────────────────
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
const officerUser = req.headers.get('X-Officer-User');
if (!officerUser) return json({ error: 'missing X-Officer-User' }, 401);
const userId = Number(officerUser);
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
if (segments.length === 0) return null;
const ctx: OfficerContext = { req, url, userId };
try {
switch (segments[0]) {
case 'config':
return handleConfig();
case 'wallets':
return await handleWallets(ctx, segments.slice(1));
default:
return null;
}
} catch (err) {
if (err instanceof BackendError) {
return json({ error: err.message, code: err.code }, err.status);
}
throw err;
}
}
/** Non-secret deployment facts the UI needs before any wallet exists. */
function handleConfig(): Response {
const cfg = getConfig();
return json({
network: cfg.network,
esploraUrl: cfg.esploraUrl,
unlockTtlSec: cfg.unlockTtlSec,
// The UI blocks wallet creation on this rather than letting the first write fail on a crypto error.
storeKeyConfigured: hasStoreKey(),
});
}
// ── /wallets ─────────────────────────────────────────────────────────────────────────────────────
const KINDS: readonly WalletKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];
async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Response | null> {
const { req, userId } = ctx;
// /_officer/wallets
if (seg.length === 0) {
if (req.method === 'GET') return json({ wallets: await listWallets(userId) });
if (req.method === 'POST') return await createWalletRoute(ctx);
return json({ error: 'method not allowed' }, 405);
}
// /_officer/wallets/active — resolves to whichever wallet is currently selected
if (seg[0] === 'active' && seg.length === 1 && req.method === 'GET') {
const active = await getActiveWallet(userId);
return json({ wallet: active });
}
const walletId = Number(seg[0]);
if (!Number.isInteger(walletId) || walletId <= 0) return badRequest('invalid wallet id');
const rest = seg.slice(1);
// /_officer/wallets/:id
if (rest.length === 0) {
if (req.method === 'GET') {
const wallet = await getWallet(userId, walletId);
return wallet ? json({ wallet }) : json({ error: 'wallet not found' }, 404);
}
if (req.method === 'PATCH') {
const patch = await body<{ name?: string; defaultBip?: number; config?: Record<string, unknown> }>(req);
const updated = await updateWallet(userId, walletId, patch);
invalidate(walletId);
return updated ? json({ wallet: updated }) : json({ error: 'wallet not found' }, 404);
}
if (req.method === 'DELETE') return await deleteWalletRoute(ctx, walletId);
return json({ error: 'method not allowed' }, 405);
}
// Seed / lock lifecycle — matched before the generic operations below.
switch (rest[0]) {
case 'activate':
if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
await setActiveWallet(userId, walletId);
return json({ ok: true });
case 'lock-state': {
const wallet = await getWallet(userId, walletId);
if (!wallet) return json({ error: 'wallet not found' }, 404);
const session = sessionFor(walletId);
return json({
hasSeed: wallet.hasSeed,
unlocked: session.isUnlocked(),
secondsRemaining: session.secondsRemaining(),
});
}
case 'unlock':
return await unlockRoute(ctx, walletId);
case 'lock':
if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
sessionFor(walletId).lock();
return json({ ok: true, unlocked: false });
case 'passphrase':
return await changePassphraseRoute(ctx, walletId);
case 'export-seed':
return await exportSeedRoute(ctx, walletId);
}
// Everything else needs a live backend.
const { wallet, backend } = await resolveBackend(userId, walletId);
switch (rest[0]) {
case 'capabilities': {
const all: Capability[] = [
'onchainReceive',
'onchainSend',
'coinControl',
'psbt',
'bumpFee',
'sweep',
'accounts',
'lightningReceive',
'lightningSend',
'keysend',
'customPreimages',
'offers',
'channels',
'peers',
'routing',
'signMessage',
];
return json({ kind: wallet.kind, capabilities: all.filter((c) => backend.supports(c)) });
}
case 'info':
return json({ info: await backend.getInfo() });
case 'balances':
return json({ balances: await backend.getBalances() });
case 'transactions': {
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
const txs = await backend.getTransactions({ limit });
// Overlay owner labels, which live in Officer's DB rather than any backend.
const labels = await getWalletLabels(walletId);
const byRef = new Map(labels.filter((l) => l.kind === 'tx').map((l) => [l.ref, l.label]));
return json({ transactions: txs.map((t) => ({ ...t, label: byRef.get(t.txid) ?? t.label })) });
}
case 'address': {
requireCap(backend, 'onchainReceive', 'receiving on-chain');
const peek = ctx.url.searchParams.get('peek') === 'true';
// An explicit ?type= wins; otherwise the wallet's defaultBip decides. Without this the on-chain
// backend would always fall back to its own preference order (native segwit), silently ignoring an
// owner who set the wallet to taproot or legacy.
const asked = ctx.url.searchParams.get('type');
if (asked && !asAddressType(asked)) return badRequest(`unknown address type "${asked}"`);
const type = asked ? asAddressType(asked) : BIP_ADDRESS_TYPE[String(wallet.defaultBip)];
return json(await backend.getNewAddress({ peek, type }));
}
case 'utxos':
return await utxosRoute(ctx, walletId, backend, rest.slice(1));
case 'fees':
return json({ fees: await backend.estimateFees() });
case 'send': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
requireCap(backend, 'onchainSend', 'sending on-chain');
// The lock gate comes before body validation, not after. The backend checks it too, but only once
// the request has already passed every field check here — so a locked wallet was answering "your
// fee rate is wrong" instead of "unlock me first", which sends the UI down the wrong path.
if (wallet.hasSeed && !sessionFor(walletId).isUnlocked()) throw new WalletLockedError();
const req2 = await body<Parameters<WalletBackend['sendCoins']>[0]>(ctx.req);
if (!req2.address) return badRequest('address is required');
if (!req2.sendAll && !req2.amountSats) return badRequest('amountSats or sendAll is required');
if (!req2.satPerVbyte || req2.satPerVbyte < 1) return badRequest('satPerVbyte must be at least 1');
// Never spend a frozen coin, even if the caller passed no explicit outpoint list.
const frozen = new Set(await getFrozenOutpoints(walletId));
if (req2.outpoints?.some((o) => frozen.has(o))) return badRequest('refusing to spend a frozen UTXO');
const result = await backend.sendCoins(req2);
if (req2.label) await setWalletLabel(walletId, 'tx', result.txid, req2.label);
return json(result);
}
case 'invoices':
return await invoicesRoute(ctx, backend, rest.slice(1));
case 'decode': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { bolt11 } = await body<{ bolt11?: string }>(ctx.req);
if (!bolt11) return badRequest('bolt11 is required');
return json({ decoded: await backend.decodeInvoice(bolt11) });
}
case 'payments': {
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
return json({ payments: await backend.getPayments({ limit }) });
}
case 'pay': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
requireCap(backend, 'lightningSend', 'paying lightning invoices');
const payReq = await body<Parameters<WalletBackend['payInvoice']>[0]>(ctx.req);
if (!payReq.bolt11) return badRequest('bolt11 is required');
if (payReq.feeLimitMsat && payReq.feeLimitPercent) {
return badRequest('feeLimitMsat and feeLimitPercent are mutually exclusive');
}
return json({ payment: await backend.payInvoice(payReq) });
}
case 'keysend': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
requireCap(backend, 'keysend', 'keysend');
const ks = await body<Parameters<WalletBackend['sendKeysend']>[0]>(ctx.req);
if (!ks.destination || !ks.amountMsat) return badRequest('destination and amountMsat are required');
return json({ payment: await backend.sendKeysend(ks) });
}
case 'channels':
requireCap(backend, 'channels', 'channels');
return json({ channels: await backend.getChannels() });
case 'peers':
requireCap(backend, 'peers', 'peers');
return json({ peers: await backend.getPeers() });
case 'sign': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
requireCap(backend, 'signMessage', 'message signing');
const { message } = await body<{ message?: string }>(ctx.req);
if (!message) return badRequest('message is required');
return json(await backend.signMessage(message));
}
case 'verify': {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
requireCap(backend, 'signMessage', 'message verification');
const { message, signature } = await body<{ message?: string; signature?: string }>(ctx.req);
if (!message || !signature) return badRequest('message and signature are required');
return json(await backend.verifyMessage(message, signature));
}
case 'labels': {
if (ctx.req.method === 'GET') return json({ labels: await getWalletLabels(walletId) });
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { kind, ref, label } = await body<{ kind?: string; ref?: string; label?: string }>(ctx.req);
if (kind !== 'address' && kind !== 'tx') return badRequest('kind must be "address" or "tx"');
if (!ref) return badRequest('ref is required');
await setWalletLabel(walletId, kind, ref, label ?? '');
return json({ ok: true });
}
default:
return null;
}
}
// ── wallet lifecycle ─────────────────────────────────────────────────────────────────────────────
type CreateBody = {
name?: string;
kind?: string;
network?: string;
/** onchain only: omit `mnemonic` to generate a fresh seed. */
mnemonic?: string;
words?: 12 | 24;
passphrase?: string;
bip39Passphrase?: string;
defaultBip?: number;
config?: Record<string, unknown>;
makeActive?: boolean;
};
async function createWalletRoute(ctx: OfficerContext): Promise<Response> {
if (!hasStoreKey()) {
throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY');
}
const b = await body<CreateBody>(ctx.req);
if (!b.name?.trim()) return badRequest('name is required');
if (!b.kind || !(KINDS as readonly string[]).includes(b.kind)) {
return badRequest(`kind must be one of ${KINDS.join(', ')}`);
}
const kind = b.kind as WalletKind;
const network = (b.network ?? getConfig().network) as BitcoinNetwork;
// Remote-node wallets: store the connection config, no seed involved.
if (kind !== 'onchain') {
if (!b.config) return badRequest(`${kind} wallets require a config object`);
const wallet = await createWallet({
userId: ctx.userId,
name: b.name.trim(),
kind,
network,
config: b.config,
makeActive: b.makeActive ?? true,
});
return json({ wallet }, 201);
}
// Self-custodial on-chain wallet: seal a seed under the owner passphrase.
if (!b.passphrase) return badRequest('passphrase is required for a seeded wallet');
const mnemonic = b.mnemonic?.trim() || generateSeed(b.words ?? 24);
const envelope = await sealSeed(mnemonic, b.passphrase, b.bip39Passphrase);
const { fingerprint, xpubs } = await deriveAccountXpubs(envelope, b.passphrase, network);
const wallet = await createWallet({
userId: ctx.userId,
name: b.name.trim(),
kind,
network,
sealedSeed: JSON.stringify(envelope),
fingerprint,
xpubs: Object.fromEntries(Object.entries(xpubs).map(([k, v]) => [k, v])),
defaultBip: b.defaultBip ?? 84,
makeActive: b.makeActive ?? true,
});
// Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and
// has no other chance to see it without re-entering the passphrase. An imported mnemonic is never
// echoed back: the caller already has it, and echoing would put it in a response log for no reason.
return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201);
}
async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
const wallet = await getWallet(ctx.userId, walletId);
if (!wallet) return json({ error: 'wallet not found' }, 404);
// Deleting a seeded wallet destroys the only copy of the key material Officer holds. Require the
// passphrase, even when the wallet is already unlocked — an open session must not be enough.
if (wallet.hasSeed) {
const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
if (!passphrase) return badRequest('passphrase is required to delete a seeded wallet');
const sealed = await getSealedSeed(ctx.userId, walletId);
if (!sealed) throw new BackendError('wallet seed is missing', 500, 'NO_SEED');
if (!(await verifyPassphrase(JSON.parse(sealed) as SeedEnvelope, passphrase))) {
return json({ error: 'incorrect passphrase' }, 401);
}
}
sessionFor(walletId).lock();
invalidate(walletId);
const deleted = await deleteWallet(ctx.userId, walletId);
return deleted ? json({ ok: true }) : json({ error: 'wallet not found' }, 404);
}
// ── lock lifecycle ───────────────────────────────────────────────────────────────────────────────
async function loadEnvelope(userId: number, walletId: number): Promise<SeedEnvelope> {
const sealed = await getSealedSeed(userId, walletId);
if (!sealed) throw new BackendError('this wallet holds no seed', 400, 'NO_SEED');
return JSON.parse(sealed) as SeedEnvelope;
}
async function unlockRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { passphrase, ttlSec } = await body<{ passphrase?: string; ttlSec?: number }>(ctx.req);
if (!passphrase) return badRequest('passphrase is required');
const env = await loadEnvelope(ctx.userId, walletId);
const session = sessionFor(walletId);
const max = getConfig().unlockTtlSec;
// A caller may shorten the window but never extend it past the deployment's configured maximum.
await session.unlock(env, passphrase, Math.min(ttlSec ?? max, max));
return json({ ok: true, unlocked: true, secondsRemaining: session.secondsRemaining() });
}
async function changePassphraseRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { oldPassphrase, newPassphrase } = await body<{ oldPassphrase?: string; newPassphrase?: string }>(ctx.req);
if (!oldPassphrase || !newPassphrase) return badRequest('oldPassphrase and newPassphrase are required');
const env = await loadEnvelope(ctx.userId, walletId);
const resealed = await changePassphrase(env, oldPassphrase, newPassphrase);
await updateWallet(ctx.userId, walletId, { sealedSeed: JSON.stringify(resealed) });
// Force a re-unlock under the new passphrase rather than leaving a session opened by the old one.
sessionFor(walletId).lock();
return json({ ok: true });
}
async function exportSeedRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
if (!passphrase) return badRequest('passphrase is required');
const env = await loadEnvelope(ctx.userId, walletId);
const mnemonic = await exportMnemonic(env, passphrase);
console.warn(`[wallet] seed exported for wallet ${walletId} by user ${ctx.userId}`);
return json({ mnemonic, hasBip39Passphrase: env.hasBip39Passphrase });
}
// ── utxos ────────────────────────────────────────────────────────────────────────────────────────
async function utxosRoute(
ctx: OfficerContext,
walletId: number,
backend: WalletBackend,
seg: string[],
): Promise<Response> {
requireCap(backend, 'coinControl', 'coin control');
if (seg[0] === 'freeze') {
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
const { outpoint, frozen, reason } = await body<{ outpoint?: string; frozen?: boolean; reason?: string }>(ctx.req);
if (!outpoint || !/^[0-9a-f]{64}:\d+$/i.test(outpoint)) return badRequest('outpoint must be "txid:vout"');
await setUtxoFrozen(walletId, outpoint, frozen !== false, reason);
return json({ ok: true });
}
const [utxos, frozenList, labels] = await Promise.all([
backend.getUtxos(),
getFrozenOutpoints(walletId),
getWalletLabels(walletId),
]);
const frozen = new Set(frozenList);
const byAddr = new Map(labels.filter((l) => l.kind === 'address').map((l) => [l.ref, l.label]));
// The freeze flag is Officer's, not the backend's — overlay it here so coin control is consistent
// across every backend, including ones with no freeze concept of their own.
const merged: (Utxo & { label: string | null })[] = utxos.map((u) => ({
...u,
frozen: frozen.has(`${u.txid}:${u.vout}`),
label: byAddr.get(u.address) ?? null,
}));
return json({ utxos: merged });
}
// ── invoices ─────────────────────────────────────────────────────────────────────────────────────
async function invoicesRoute(ctx: OfficerContext, backend: WalletBackend, seg: string[]): Promise<Response> {
// /_officer/wallets/:id/invoices/:paymentHash
if (seg.length === 1) {
const invoice = await backend.lookupInvoice(seg[0]!);
return invoice ? json({ invoice }) : json({ error: 'invoice not found' }, 404);
}
if (ctx.req.method === 'GET') {
requireCap(backend, 'lightningReceive', 'lightning');
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
return json({ invoices: await backend.getInvoices({ limit }) });
}
if (ctx.req.method === 'POST') {
requireCap(backend, 'lightningReceive', 'creating invoices');
const req = await body<Parameters<WalletBackend['createInvoice']>[0]>(ctx.req);
if (req.preimage) requireCap(backend, 'customPreimages', 'custom preimages');
return json({ invoice: await backend.createInvoice(req) }, 201);
}
return json({ error: 'method not allowed' }, 405);
}
+315
View File
@@ -0,0 +1,315 @@
// The wallet sidecar's backend contract — a typed re-statement of what Zeus's utils/BackendUtils.ts
// dispatches to duck-typed across its seven backends.
//
// Two things are deliberately NOT copied from Zeus:
//
// 1. Zeus's `call(funcName, args)` returns `false` when a backend lacks a method, so an unsupported
// feature and a typo'd method name are indistinguishable at the call site. Here the surface is a
// TypeScript interface and capability negotiation is explicit via `supports()`.
// 2. Zeus threads URL path segments through the interface — `decodePaymentRequest(urlParams[0])`,
// `closeChannel(urlParams)`. Every operation below takes a named request object.
//
// UNITS. Amounts are satoshis as `number` (max 2.1e15, safely inside 2^53) and millisatoshis as
// decimal `string` (2.1e18 overflows a double). Never widen a msat to a number.
export type BackendKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
export type BitcoinNetwork = 'bitcoin' | 'testnet' | 'signet' | 'regtest';
/**
* Explicit capability flags, replacing Zeus's ~60 `supportsX()` predicates (BackendUtils.ts:200-270).
* A backend declares these once; routes.ts checks before dispatching so an unsupported operation is a
* clean 501 rather than a mystery failure deep in an upstream call.
*/
export type Capability =
// on-chain
| 'onchainReceive'
| 'onchainSend'
| 'coinControl'
| 'psbt'
| 'bumpFee'
| 'sweep'
| 'accounts'
// lightning
| 'lightningReceive'
| 'lightningSend'
| 'keysend'
| 'customPreimages'
| 'offers'
// node operation
| 'channels'
| 'peers'
| 'routing'
| 'signMessage';
// ── node / balances ──────────────────────────────────────────────────────────────────────────────
export type NodeInfo = {
kind: BackendKind;
/** Node pubkey, or null for backends with no node identity of their own (onchain, lndhub). */
pubkey: string | null;
alias: string | null;
/** Upstream implementation version, verbatim. Null when the backend cannot report one. */
version: string | null;
network: BitcoinNetwork;
blockHeight: number | null;
synced: boolean;
};
export type Balances = {
/** Spendable on-chain, in sats. */
onchainConfirmed: number;
onchainUnconfirmed: number;
/** Sum of local balance across active channels, in sats. Null when the backend has no channels. */
lightningBalance: number | null;
/** Sum of remote balance — i.e. inbound liquidity — in sats. */
lightningInbound: number | null;
};
// ── on-chain ─────────────────────────────────────────────────────────────────────────────────────
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
/**
* BIP44 purpose → the script type it derives. Keyed by the string form, because that is how a wallet's
* `xpubs` map arrives from the database (JSON object keys are always strings).
*/
export const BIP_ADDRESS_TYPE: Record<string, AddressType> = {
'44': 'p2pkh',
'49': 'p2sh-p2wpkh',
'84': 'p2wpkh',
'86': 'p2tr',
};
/** Narrow caller-supplied text to an AddressType, or undefined if it names none. */
export function asAddressType(value: string): AddressType | undefined {
return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh' ? value : undefined;
}
export type OnchainTx = {
txid: string;
/** Net effect on this wallet in sats — negative for a spend. */
amount: number;
feeSats: number | null;
blockHeight: number | null;
/** Unix seconds. Null while unconfirmed on backends that do not timestamp the mempool. */
timestamp: number | null;
confirmations: number;
label: string | null;
destAddresses: string[];
/** Raw tx hex when the backend supplies it, for RBF/inspection. */
rawHex: string | null;
};
export type Utxo = {
txid: string;
vout: number;
amountSats: number;
address: string;
addressType: AddressType | null;
confirmations: number;
/** BIP32 path relative to the account xpub, when this UTXO belongs to a derived address. */
derivationPath: string | null;
/** Frozen UTXOs are excluded from automatic coin selection. */
frozen: boolean;
};
export type FeeEstimates = {
/** sat/vB, keyed by confirmation target in blocks. */
fastestFee: number;
halfHourFee: number;
hourFee: number;
economyFee: number;
minimumFee: number;
};
export type NewAddressRequest = {
type?: AddressType;
/** Return the current unused address instead of advancing the derivation index. */
peek?: boolean;
};
export type SendCoinsRequest = {
address: string;
/** Ignored when `sendAll` is set. */
amountSats?: number;
sendAll?: boolean;
satPerVbyte: number;
/** Coin control: restrict inputs to these outpoints (`txid:vout`). Requires the `coinControl` capability. */
outpoints?: string[];
spendUnconfirmed?: boolean;
/** Opt out of RBF signalling. Default is replaceable. */
rbf?: boolean;
label?: string;
};
export type SendCoinsResult = {
txid: string;
feeSats: number;
rawHex: string | null;
};
// ── lightning ────────────────────────────────────────────────────────────────────────────────────
export type InvoiceState = 'open' | 'settled' | 'canceled' | 'accepted' | 'expired';
export type Invoice = {
/** Payment hash, hex. */
paymentHash: string;
/** BOLT11 payment request. */
bolt11: string;
amountMsat: string | null;
amountPaidMsat: string | null;
memo: string | null;
state: InvoiceState;
createdAt: number;
expiresAt: number | null;
settledAt: number | null;
/** Only ever populated for invoices this wallet created and only when explicitly requested. */
preimage: string | null;
isKeysend: boolean;
isAmp: boolean;
};
export type CreateInvoiceRequest = {
amountMsat?: string;
memo?: string;
expirySeconds?: number;
/** Requires the `customPreimages` capability. */
preimage?: string;
isAmp?: boolean;
private?: boolean;
};
export type DecodedInvoice = {
bolt11: string;
paymentHash: string;
amountMsat: string | null;
description: string | null;
destination: string;
timestamp: number;
expiry: number;
cltvExpiry: number | null;
routeHints: boolean;
features: string[];
};
export type PaymentStatus = 'pending' | 'succeeded' | 'failed';
export type Payment = {
paymentHash: string;
preimage: string | null;
amountMsat: string;
feeMsat: string;
status: PaymentStatus;
createdAt: number;
destination: string | null;
memo: string | null;
/** Upstream failure reason, verbatim, when status is 'failed'. */
failureReason: string | null;
};
export type PayInvoiceRequest = {
bolt11: string;
/** Required when the invoice is zero-amount; rejected otherwise. */
amountMsat?: string;
/** Absolute cap in msat. Mutually exclusive with feeLimitPercent. */
feeLimitMsat?: string;
feeLimitPercent?: number;
timeoutSeconds?: number;
};
export type KeysendRequest = {
destination: string;
amountMsat: string;
feeLimitMsat?: string;
message?: string;
};
// ── channels / peers ─────────────────────────────────────────────────────────────────────────────
export type Channel = {
channelId: string;
channelPoint: string | null;
remotePubkey: string;
remoteAlias: string | null;
capacitySats: number;
localBalanceSats: number;
remoteBalanceSats: number;
active: boolean;
private: boolean;
/** 'open' | 'pending-open' | 'pending-close' | 'force-closing' | 'closed' */
status: string;
};
export type Peer = {
pubkey: string;
address: string;
alias: string | null;
inbound: boolean;
};
// ── signing ──────────────────────────────────────────────────────────────────────────────────────
export type SignMessageResult = { signature: string };
export type VerifyMessageResult = { valid: boolean; pubkey: string | null };
// ── the interface ────────────────────────────────────────────────────────────────────────────────
/**
* Every method may throw `BackendError`. Methods guarded by a capability must only be called after
* `supports()` returns true — the base class throws `notSupported()` otherwise, so a missed guard is a
* loud 501 rather than Zeus's silent `false`.
*/
export interface WalletBackend {
readonly kind: BackendKind;
supports(cap: Capability): boolean;
getInfo(): Promise<NodeInfo>;
getBalances(): Promise<Balances>;
// on-chain
getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]>;
getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }>;
getUtxos(): Promise<Utxo[]>;
estimateFees(): Promise<FeeEstimates>;
sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult>;
// lightning
getInvoices(opts?: { limit?: number }): Promise<Invoice[]>;
createInvoice(req: CreateInvoiceRequest): Promise<Invoice>;
lookupInvoice(paymentHash: string): Promise<Invoice | null>;
decodeInvoice(bolt11: string): Promise<DecodedInvoice>;
getPayments(opts?: { limit?: number }): Promise<Payment[]>;
payInvoice(req: PayInvoiceRequest): Promise<Payment>;
sendKeysend(req: KeysendRequest): Promise<Payment>;
// node operation
getChannels(): Promise<Channel[]>;
getPeers(): Promise<Peer[]>;
signMessage(message: string): Promise<SignMessageResult>;
verifyMessage(message: string, signature: string): Promise<VerifyMessageResult>;
}
// ── errors ───────────────────────────────────────────────────────────────────────────────────────
export class BackendError extends Error {
constructor(
message: string,
readonly status: number = 502,
readonly code?: string,
) {
super(message);
this.name = 'BackendError';
}
}
/** The wallet holds a seed but is currently locked — signing is impossible until /unlock. */
export class WalletLockedError extends BackendError {
constructor() {
super('wallet is locked', 423, 'WALLET_LOCKED');
this.name = 'WalletLockedError';
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { BitcoinNetwork } from './types';
// The ONLY reader of WALLET_* env in the tree. Everything else — node URLs, macaroons, runes, LNDHub
// credentials, NWC URIs — is per-wallet configuration the owner enters at runtime and lives encrypted in
// Postgres (databases/officer_db/src/schema/wallet.ts), not here. Env holds only what is genuinely
// deployment-wide: which chain we're on and where to get chain data.
const NETWORKS: readonly BitcoinNetwork[] = ['bitcoin', 'testnet', 'signet', 'regtest'];
// mempool.space's public API. Fine to start on; swap it for your own electrs/esplora when the node is up
// — an Esplora endpoint sees every address in the wallet, so the public one is a privacy leak, not a
// custody one. It never sees a private key and cannot authorize anything.
const DEFAULT_ESPLORA: Record<BitcoinNetwork, string> = {
bitcoin: 'https://mempool.space/api',
testnet: 'https://mempool.space/testnet/api',
signet: 'https://mempool.space/signet/api',
regtest: 'http://127.0.0.1:3002',
};
export type WalletConfig = {
network: BitcoinNetwork;
esploraUrl: string;
unlockTtlSec: number;
};
let warned = false;
export function getConfig(): WalletConfig {
const raw = process.env.WALLET_NETWORK?.trim() ?? 'bitcoin';
const network = (NETWORKS as readonly string[]).includes(raw) ? (raw as BitcoinNetwork) : 'bitcoin';
if (raw && network !== raw && !warned) {
console.warn(`[wallet] WALLET_NETWORK="${raw}" is not a known network, falling back to bitcoin`);
warned = true;
}
const esploraUrl = process.env.WALLET_ESPLORA_URL?.trim().replace(/\/+$/, '') || DEFAULT_ESPLORA[network];
const ttl = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900);
return {
network,
esploraUrl,
// Clamp: a zero TTL makes the wallet unusable, and an unbounded one defeats auto-lock entirely.
unlockTtlSec: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 30), 86_400) : 900,
};
}
/**
* Whether VAULT_STORE_KEY is present. The sidecar can serve a locked, watch-only view without it, but
* every write path that touches an encrypted column will throw, so /_health reports it explicitly rather
* than letting the first wallet creation fail with a confusing crypto error.
*/
export function hasStoreKey(): boolean {
const k = process.env.VAULT_STORE_KEY;
return Boolean(k && k.length >= 16);
}