bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision rather than a mechanical move. The wallet permission was kind: 'admin' — owner only, not grantable, invisible to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts forces it and the manifest has no field for anything else, deliberately, so that "can a plugin escalate itself" is a question which cannot be asked. So extraction could not preserve admin, and that was raised as a blocker before any code moved. It was the right change anyway, and the objection inverts. Every wallet query is scoped by user_id, and that id comes from X-Officer-User which the platform injects from the authenticated session — a caller cannot name someone else's. So granting this gives an account ITS OWN wallet. Not through the API and not with root either does it hand over anyone else's, because their seed is sealed under a passphrase the server never stores. kind: 'admin' was single-user thinking; grantable is how a second person gets a wallet at all. Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically, and the appName drives the route, the API prefix, the table prefix and the install key together. Tables renamed to bitcoin_wallet_* accordingly — the prefix guard normalises the hyphen. Free only because no wallet table existed on any server, which was checked before deciding: db:push treats a rename as a drop plus a create. The three custody test files moved with it and still run — 72 tests over scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by nine, so that trap was already closed. 68 palette classes converted, five status vocabularies hoisted into shared.ts. Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all read as warning, and the bitcoin and lightning rails no longer differ by hue. Deferred on purpose: watch-only data (xpubs, balances, history) stays unencrypted so a locked wallet still shows a balance. Anyone with the database can therefore SEE any user's balances; they can never move a coin.
This commit is contained in:
@@ -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
@@ -0,0 +1,860 @@
|
||||
// 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),
|
||||
onchainFrozen: 0,
|
||||
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,602 @@
|
||||
// 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,
|
||||
onchainFrozen: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
lightningBalance: num(res?.BTC?.AvailableBalance),
|
||||
// Inbound liquidity is the operator's problem and is never reported.
|
||||
lightningInbound: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── on-chain ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `/gettxs` mixes `paid_invoice` (lightning, outgoing) with `bitcoind_tx` (on-chain deposits copied
|
||||
* out of bitcoind's listtransactions). Only the latter belong here.
|
||||
*/
|
||||
override async getTransactions(opts?: { limit?: number }): Promise<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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
// 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,
|
||||
onchainFrozen: 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,822 @@
|
||||
// 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 through
|
||||
// an injected WalletChainSource (chain-source.ts), and builds/signs its own transactions with
|
||||
// bitcoinjs-lib (psbt.ts).
|
||||
//
|
||||
// It owns the KEYS and the MONEY: derivation, the snapshot cache, coin selection, PSBT construction,
|
||||
// signing. It owns no HTTP at all. Which indexer answers — a public Esplora, the owner's own node behind
|
||||
// NBXplorer — is a constructor argument, and nothing below this line knows the difference.
|
||||
//
|
||||
// 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 type { WalletChainSnapshot } from '../../db';
|
||||
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 {
|
||||
mapLimit,
|
||||
MAX_SCAN_INDEX,
|
||||
REQUEST_CONCURRENCY,
|
||||
type AddressEntry,
|
||||
type ChainIndex,
|
||||
type ScanContext,
|
||||
type ScannedAddress,
|
||||
type WalletChainSource,
|
||||
} from '../chain-source';
|
||||
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 RescanOptions,
|
||||
type RescanState,
|
||||
type SendCoinsRequest,
|
||||
type SendCoinsResult,
|
||||
type SignMessageResult,
|
||||
type SyncState,
|
||||
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;
|
||||
}
|
||||
|
||||
// ── the persistence boundary ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Durable storage for the last snapshot this backend read off the chain. Injected the same way the
|
||||
* signer is, and for the same reason: this file stays a pure wallet with no idea which database it is
|
||||
* attached to, or that it has a wallet id at all. resolve.ts binds the real implementation.
|
||||
*
|
||||
* Every method is best-effort by contract. A cache that is unreachable, corrupt or empty must degrade
|
||||
* to today's behaviour — a slow first load — and must never fail a wallet request.
|
||||
*/
|
||||
export interface ChainCacheStore {
|
||||
load(): Promise<{ snapshot: WalletChainSnapshot | null; syncedAt: Date | null; lastError: string | null } | null>;
|
||||
save(snapshot: WalletChainSnapshot): Promise<void>;
|
||||
recordError(message: string): Promise<void>;
|
||||
}
|
||||
|
||||
// ── 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'];
|
||||
|
||||
type Account = {
|
||||
type: AddressType;
|
||||
node: HDKey;
|
||||
/** The extended key exactly as it was given. Wallet-level chain sources index by this string. */
|
||||
accountXpub: string;
|
||||
/** Account-level path from the wallet root, e.g. `m/84'/0'/0'`. */
|
||||
basePath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One coherent read of the chain: the address scan and everything derived from it, taken together.
|
||||
*
|
||||
* Balances, UTXOs and history used to be fetched by three separate endpoints on top of a shared scan,
|
||||
* which meant the three queries a wallet screen fires on mount could each observe a different moment.
|
||||
* Building them as one snapshot costs the same requests — collectUtxos only asks about funded addresses
|
||||
* and collectTxs only about touched ones — and makes the whole thing a single unit to cache, serve stale
|
||||
* and persist.
|
||||
*/
|
||||
type Snapshot = {
|
||||
/** Unix ms the chain was read. Survives a restart: a restored snapshot carries its original time. */
|
||||
at: number;
|
||||
tipHeight: number;
|
||||
addresses: ScannedAddress[];
|
||||
utxos: SpendableUtxo[];
|
||||
txs: OnchainTx[];
|
||||
};
|
||||
|
||||
// ── tuning ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** How long a snapshot is served without kicking off a refresh behind it. */
|
||||
const SCAN_TTL_MS = 30_000;
|
||||
|
||||
const DEFAULT_TX_LIMIT = 100;
|
||||
|
||||
// ── the backend ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OnchainBackendOptions = {
|
||||
chain: WalletChainSource;
|
||||
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;
|
||||
/** Omit for a backend that should hold its chain view in memory only — tests do. */
|
||||
cache?: ChainCacheStore;
|
||||
};
|
||||
|
||||
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: WalletChainSource;
|
||||
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 readonly cache: ChainCacheStore | null;
|
||||
|
||||
private current: Snapshot | null = null;
|
||||
private inflight: Promise<Snapshot> | null = null;
|
||||
/** Memoised so concurrent first reads do one cache load between them, not one each. */
|
||||
private hydration: Promise<void> | null = null;
|
||||
private lastError: string | null = null;
|
||||
|
||||
constructor(opts: OnchainBackendOptions) {
|
||||
super();
|
||||
initEcc();
|
||||
this.chain = opts.chain;
|
||||
this.network = opts.network;
|
||||
this.btcNetwork = networkFor(opts.network);
|
||||
this.signer = opts.signer;
|
||||
this.cache = opts.cache ?? null;
|
||||
|
||||
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),
|
||||
accountXpub: 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;
|
||||
}
|
||||
|
||||
// ── the snapshot: cache, refresh, persist ──────────────────────────────────────────────────────
|
||||
|
||||
getSyncState(): SyncState {
|
||||
return {
|
||||
syncedAt: this.current?.at ?? null,
|
||||
stale: this.current !== null && Date.now() - this.current.at >= SCAN_TTL_MS,
|
||||
lastError: this.lastError,
|
||||
canRescan: this.chain.startRescan !== undefined,
|
||||
rescan: this.chain.rescanState?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the chain source to go looking for this wallet's coins from scratch.
|
||||
*
|
||||
* Returns as soon as the search is queued — it takes minutes, and the caller is an HTTP route. The
|
||||
* refresh chained onto its completion is the part that matters: a rescan that found coins has changed
|
||||
* nothing visible until the snapshot in front of it is rebuilt, and without this the owner would be
|
||||
* staring at the same zero until the TTL happened to expire.
|
||||
*/
|
||||
async startRescan(opts: RescanOptions = {}): Promise<RescanState> {
|
||||
const start = this.chain.startRescan?.bind(this.chain);
|
||||
if (!start) this.notSupported('rescanning the chain');
|
||||
const { state, done } = start(this.scanContext().accounts, opts);
|
||||
void done
|
||||
.then(() => this.refresh())
|
||||
// The rescan's own failure is already on its state and reported through getSyncState; a failed
|
||||
// refresh behind it lands on lastError the same way. Neither should surface as an unhandled
|
||||
// rejection in the sidecar's log.
|
||||
.catch(() => {});
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach to a rescan that outlived this process, and put the refresh back on the end of it.
|
||||
*
|
||||
* Read-only with respect to the upstream: it never starts a scan, so calling it when nothing is running
|
||||
* costs one request per account and answers null.
|
||||
*/
|
||||
async adoptRescan(): Promise<RescanState | null> {
|
||||
const adopt = this.chain.adoptRescan?.bind(this.chain);
|
||||
if (!adopt) return null;
|
||||
const handle = await adopt(this.scanContext().accounts);
|
||||
if (!handle) return null;
|
||||
// The same chaining startRescan does, and for the same reason: coins recovered by a scan are invisible
|
||||
// until the snapshot in front of them is rebuilt.
|
||||
void handle.done.then(() => this.refresh()).catch(() => {});
|
||||
return handle.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wallet's view of the chain, stale-while-revalidate.
|
||||
*
|
||||
* A snapshot within SCAN_TTL_MS is served as-is. An older one is served IMMEDIATELY with a refresh
|
||||
* started behind it — that is the property the persistence exists for: after a restart, or while the
|
||||
* Esplora upstream is slow or unreachable, the screen shows the last real numbers with their age
|
||||
* rather than a blank wallet. Only a wallet that has genuinely never been read blocks on the network,
|
||||
* and only that case can surface an error to the caller.
|
||||
*
|
||||
* `fresh` opts out for the two paths where stale data would be wrong rather than merely old.
|
||||
*/
|
||||
private async snapshot(opts?: { fresh?: boolean }): Promise<Snapshot> {
|
||||
if (opts?.fresh) return this.refresh();
|
||||
|
||||
await this.hydrate();
|
||||
|
||||
const have = this.current;
|
||||
if (have && Date.now() - have.at < SCAN_TTL_MS) return have;
|
||||
|
||||
const running = this.refresh();
|
||||
if (!have) return running;
|
||||
|
||||
// The refresh outlives this response. Its failure is recorded on `lastError` and reported through
|
||||
// getSyncState, so it is handled here only to keep it from surfacing as an unhandled rejection.
|
||||
void running.catch(() => {});
|
||||
return have;
|
||||
}
|
||||
|
||||
/** Load the persisted snapshot into memory. Runs at most once, and never throws. */
|
||||
private hydrate(): Promise<void> {
|
||||
this.hydration ??= (async () => {
|
||||
const store = this.cache;
|
||||
if (!store || this.current) return;
|
||||
try {
|
||||
const row = await store.load();
|
||||
if (!row) return;
|
||||
this.lastError = row.lastError;
|
||||
// A refresh may have completed while this load was in flight; live data always wins. A snapshot
|
||||
// written by an older shape is ignored, not migrated — it is a cache, and the next refresh
|
||||
// replaces it for free.
|
||||
if (row.snapshot?.v === 1 && row.syncedAt && !this.current) {
|
||||
this.current = this.fromPersisted(row.snapshot, row.syncedAt.getTime());
|
||||
}
|
||||
} catch (err) {
|
||||
// A cache failure costs a slow load, nothing more. It must not become a wallet failure.
|
||||
console.error('[wallet] chain cache load failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
})();
|
||||
return this.hydration;
|
||||
}
|
||||
|
||||
/** Read the chain. Concurrent callers share one walk rather than each starting their own. */
|
||||
private refresh(): Promise<Snapshot> {
|
||||
this.inflight ??= this.buildSnapshot().then(
|
||||
(snap) => {
|
||||
this.inflight = null;
|
||||
this.current = snap;
|
||||
this.lastError = null;
|
||||
void this.persist(snap);
|
||||
return snap;
|
||||
},
|
||||
(err: unknown) => {
|
||||
this.inflight = null;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.lastError = message;
|
||||
void this.cache?.recordError(message).catch((e: unknown) => {
|
||||
console.error('[wallet] chain cache error write failed:', e instanceof Error ? e.message : e);
|
||||
});
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
return this.inflight;
|
||||
}
|
||||
|
||||
private async persist(snap: Snapshot): Promise<void> {
|
||||
if (!this.cache) return;
|
||||
try {
|
||||
await this.cache.save(toPersisted(snap));
|
||||
} catch (err) {
|
||||
console.error('[wallet] chain cache write failed:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a snapshot from what was stored. Only the chain's own facts were persisted; every address,
|
||||
* script and pubkey is re-derived here from the account xpub, so a restored snapshot cannot disagree
|
||||
* with the wallet's actual keys. An entry naming a script type this wallet no longer has an xpub for
|
||||
* is dropped rather than faked.
|
||||
*/
|
||||
private fromPersisted(stored: WalletChainSnapshot, at: number): Snapshot {
|
||||
const addresses: ScannedAddress[] = [];
|
||||
for (const a of stored.addresses) {
|
||||
const entry = this.tryDerive(a.type, a.chain, a.index);
|
||||
if (!entry) continue;
|
||||
addresses.push({
|
||||
...entry,
|
||||
confirmedSats: a.confirmed,
|
||||
unconfirmedSats: a.unconfirmed,
|
||||
txCount: a.txCount,
|
||||
used: a.txCount > 0,
|
||||
});
|
||||
}
|
||||
|
||||
const utxos: SpendableUtxo[] = [];
|
||||
for (const u of stored.utxos) {
|
||||
const entry = this.tryDerive(u.type, u.chain, u.index);
|
||||
if (!entry) continue;
|
||||
utxos.push({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.value,
|
||||
address: entry.address,
|
||||
addressType: entry.type,
|
||||
confirmations: u.blockHeight === null ? 0 : Math.max(0, stored.tipHeight - u.blockHeight + 1),
|
||||
derivationPath: entry.path,
|
||||
frozen: false,
|
||||
scriptPubKeyHex: entry.scriptPubKeyHex,
|
||||
pubkeyHex: entry.pubkeyHex,
|
||||
});
|
||||
}
|
||||
|
||||
return { at, tipHeight: stored.tipHeight, addresses, utxos, txs: stored.transactions };
|
||||
}
|
||||
|
||||
/** Derive from untrusted stored coordinates — returns null rather than throwing on anything unusable. */
|
||||
private tryDerive(type: string, chain: number, index: number): AddressEntry | null {
|
||||
if (!isAddressType(type) || (chain !== 0 && chain !== 1)) return null;
|
||||
if (!Number.isInteger(index) || index < 0 || index >= MAX_SCAN_INDEX) return null;
|
||||
if (!this.accounts.has(type)) return null;
|
||||
try {
|
||||
return this.derive(type, chain, index);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the source is allowed to know: the account keys, which type is ours, and how to derive.
|
||||
*
|
||||
* `derive` is bound to this backend on purpose — a source that derived addresses itself could disagree
|
||||
* with the wallet about which addresses are the wallet's, and the resulting bug would lose coins rather
|
||||
* than fail loudly.
|
||||
*/
|
||||
private scanContext(): ScanContext {
|
||||
return {
|
||||
accounts: [...this.accounts.values()].map((a) => ({ type: a.type, accountXpub: a.accountXpub })),
|
||||
defaultType: this.defaultType,
|
||||
derive: (type, chain, index) => this.derive(type, chain, index),
|
||||
};
|
||||
}
|
||||
|
||||
/** One full read of the chain, stamped with the moment it was taken. */
|
||||
private async buildSnapshot(): Promise<Snapshot> {
|
||||
const scan = await this.chain.scan(this.scanContext());
|
||||
return { at: Date.now(), ...scan };
|
||||
}
|
||||
|
||||
/** Drop everything after a send, so the spent coins cannot reappear from cache on the next read. */
|
||||
private invalidateScan(): void {
|
||||
this.current = null;
|
||||
}
|
||||
|
||||
// ── 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 snap = await this.snapshot();
|
||||
let confirmed = 0;
|
||||
let unconfirmed = 0;
|
||||
for (const entry of snap.addresses) {
|
||||
confirmed += entry.confirmedSats;
|
||||
unconfirmed += entry.unconfirmedSats;
|
||||
}
|
||||
return {
|
||||
onchainConfirmed: confirmed,
|
||||
// Filled in by the route, which is the only layer that knows what the owner froze.
|
||||
onchainFrozen: 0,
|
||||
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 snap = await this.snapshot();
|
||||
return snap.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 snap = await this.snapshot();
|
||||
return snap.txs.slice(0, opts?.limit ?? DEFAULT_TX_LIMIT);
|
||||
}
|
||||
|
||||
/** History for every address the scan found had been touched. Only those can appear in it. */
|
||||
// ── 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);
|
||||
|
||||
// Never stale. Selecting coins from a cached UTXO set would build a transaction spending outputs
|
||||
// that may already be gone, and the failure would arrive as a broadcast rejection after signing.
|
||||
const snap = await this.snapshot({ fresh: true });
|
||||
|
||||
// The snapshot comes off the chain, which knows nothing about freezing, so every UTXO in it claims
|
||||
// `frozen: false`. selectCoins has always honoured the flag — nothing ever set it, which is what made
|
||||
// a plain send happily spend a coin the owner had frozen, and made "send everything" sweep it.
|
||||
const frozen = new Set(req.frozenOutpoints ?? []);
|
||||
const utxos = frozen.size
|
||||
? snap.utxos.map((u) => (frozen.has(`${u.txid}:${u.vout}`) ? { ...u, frozen: true } : u))
|
||||
: snap.utxos;
|
||||
|
||||
const selection = selectCoins({
|
||||
utxos,
|
||||
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. */
|
||||
/**
|
||||
* 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> {
|
||||
// Deliberately never served from cache. Handing out an address whose stale record says "unused" but
|
||||
// which has been paid since is silent address reuse — a privacy leak the owner cannot see and cannot
|
||||
// undo. Failing loudly while the upstream is down is the better of the two, so this is the one read
|
||||
// that stops working offline, and on purpose.
|
||||
const snap = await this.snapshot({ fresh: true });
|
||||
const key = `${type}:${chain}`;
|
||||
const hint = this.issued.get(key) ?? 0;
|
||||
|
||||
const used = new Set<number>();
|
||||
let highest = -1;
|
||||
for (const entry of snap.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++;
|
||||
|
||||
// ...but "unused" is not the same as "findable". The mark advances on every issuance, paid or not,
|
||||
// so twenty unpaid addresses walk it past the end of the window the next scan covers, and a payment
|
||||
// to the twenty-first would never be seen again. Rather than hand out an address the wallet could
|
||||
// lose money on, stop and re-offer the last safe one: every index in that run is still virgin, so
|
||||
// repeating one is not address reuse in the sense that matters — nothing was ever received on it.
|
||||
// The floor of 0 covers a wallet the chain has never seen: Esplora short-circuits an account with no
|
||||
// history and returns no addresses at all, leaving `highest` at -1, and index 0 is then the only one
|
||||
// it will look at. Handing that out until it is paid is exactly right.
|
||||
const ceiling = Math.max(0, highest + this.chain.issueAhead);
|
||||
if (index > ceiling) index = ceiling;
|
||||
|
||||
if (!peek) this.issued.set(key, index + 1);
|
||||
return this.derive(type, chain, index);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function isAddressType(value: string): value is AddressType {
|
||||
return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a snapshot to the chain facts worth storing. Addresses, scripts and pubkeys are dropped: they
|
||||
* are pure derivation from the account xpub, so re-deriving them on load is both cheaper than storing
|
||||
* them and the only way a restored snapshot is guaranteed to match the wallet's real keys.
|
||||
*/
|
||||
function toPersisted(snap: Snapshot): WalletChainSnapshot {
|
||||
return {
|
||||
v: 1,
|
||||
tipHeight: snap.tipHeight,
|
||||
addresses: snap.addresses.map((a) => ({
|
||||
type: a.type,
|
||||
chain: a.chain,
|
||||
index: a.index,
|
||||
confirmed: a.confirmedSats,
|
||||
unconfirmed: a.unconfirmedSats,
|
||||
txCount: a.txCount,
|
||||
})),
|
||||
utxos: snap.utxos.map((u) => {
|
||||
// The last two path elements are the BIP44 chain and address index — the same tail getUtxos
|
||||
// reports as the relative path.
|
||||
const tail = u.derivationPath.split('/');
|
||||
return {
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
value: u.amountSats,
|
||||
type: u.addressType,
|
||||
chain: Number(tail.at(-2)),
|
||||
index: Number(tail.at(-1)),
|
||||
// Stored as a height, not a count, so confirmations stay correct as the tip moves on.
|
||||
blockHeight: u.confirmations > 0 ? snap.tipHeight - u.confirmations + 1 : null,
|
||||
};
|
||||
}),
|
||||
transactions: snap.txs,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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]));
|
||||
}
|
||||
Reference in New Issue
Block a user