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