Files
bitcoin-wallet/sidecar/backends/lnd.ts
T
officer a4cf939be7 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.
2026-08-19 05:18:29 +00:00

861 lines
35 KiB
TypeScript

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