Files
bitcoin-wallet/sidecar/backends/lndhub.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

603 lines
25 KiB
TypeScript

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