add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,859 @@
|
||||
// LND REST backend — ported from Zeus's `backends/LND.ts` (+ `utils/LndUtils.ts` for the address-type
|
||||
// mapping). Zeus's class is a thin, untyped RPC shim: every method returns the upstream JSON verbatim and
|
||||
// the view layer does the unit conversion. Here the conversion happens once, at the edge, against the
|
||||
// `WalletBackend` contract in `../types.ts`.
|
||||
//
|
||||
// INTENTIONAL BEHAVIOURAL DIFFERENCES FROM ZEUS
|
||||
//
|
||||
// 1. No Tor. Zeus routes through `doTorRequest` when `enableTor` is set. A sidecar on the owner's own box
|
||||
// reaches its node directly; if Tor is ever needed it belongs in the dispatcher, not per backend.
|
||||
// 2. No in-flight call de-duplication. Zeus keys a module-level `calls` Map by url+body so a second
|
||||
// identical request joins the first, and needs `clearCachedCalls()` to escape a poisoned entry. That
|
||||
// cache is a mobile-battery optimisation and a footgun (a failed call can be re-awaited); dropped.
|
||||
// 3. No `forcedTimeout` race. Zeus races `payLightningInvoice` against a promise that RESOLVES with a fake
|
||||
// `{payment_error: 'timed out'}` after timeout+1s — so a slow payment silently reports failure while
|
||||
// still in flight. We pass a real deadline to `httpJson` and let it abort, surfacing a 502.
|
||||
// 4. TLS verification is opt-in per node (`allowSelfSigned`), not the blanket `trusty: !certVerification`.
|
||||
// 5. Version gating is OPTIMISTIC until `getInfo()` runs. Zeus reads `nodeInfoStore.version`, which is
|
||||
// empty before the first getinfo, so every version-gated `supportsX()` answers false. Here the gated
|
||||
// capabilities (coinControl, accounts, offers) start ON and are switched OFF once getInfo reports an
|
||||
// older node — a pre-getInfo call to an old node fails upstream instead of 501-ing on a modern one.
|
||||
// 6. `offers` is declared for v0.18+. Zeus hardcodes `supportsOffers = () => false` for LND.
|
||||
// 7. Websocket-only endpoints are not ported: `openChannelStream`, `initChanAcceptor`,
|
||||
// `subscribeCustomMessages`, `subscribeInvoice`, `subscribeTransactions`. `WalletBackend` has no
|
||||
// streaming surface.
|
||||
// 8. `sendCoins` reports `feeSats: 0`. LND's SendCoinsResponse carries only `txid`; the fee is only
|
||||
// observable afterwards through GetTransactions, and we do not block the send on a second round trip.
|
||||
// 9. `SendCoinsRequest.rbf` is ignored — lnrpc.SendCoins has no replaceability flag to forward.
|
||||
// 10. `Utxo.derivationPath` is always null. Zeus reads paths from a separate ListAddresses call (v0.18+);
|
||||
// joining that per-UTXO here would double the request count for a field nothing currently consumes.
|
||||
// 11. `Peer.alias` is always null, matching Zeus's `listPeers` — /v1/peers has no alias, and resolving one
|
||||
// costs a graph lookup per peer. Channels DO get an alias, via ListChannels' own `peer_alias_lookup`.
|
||||
// 12. `InvoiceState.expired` is derived (unsettled + past `creation_date + expiry`); lnrpc's Invoice.state
|
||||
// enum has no EXPIRED member.
|
||||
// 13. `estimateFees` uses walletrpc EstimateFee. Zeus does not ask LND for on-chain fees at all — it reads
|
||||
// mempool.space in the app layer.
|
||||
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
BackendError,
|
||||
type AddressType,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type Channel,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type FeeEstimates,
|
||||
type Invoice,
|
||||
type InvoiceState,
|
||||
type KeysendRequest,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type PaymentStatus,
|
||||
type Peer,
|
||||
type SendCoinsRequest,
|
||||
type SendCoinsResult,
|
||||
type SignMessageResult,
|
||||
type Utxo,
|
||||
type VerifyMessageResult,
|
||||
} from '../types';
|
||||
import { BaseBackend, base64ToHex, hexToBase64, httpJson } from './base';
|
||||
|
||||
// ── upstream wire types ──────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Field names are lnrpc's own, verbatim. grpc-gateway renders every int64 as a decimal STRING and every
|
||||
// `bytes` field as base64 — both quirks are load-bearing below, so the types record them exactly.
|
||||
|
||||
type LndChain = { chain?: string; network?: string };
|
||||
|
||||
type LndGetInfo = {
|
||||
version?: string;
|
||||
identity_pubkey?: string;
|
||||
alias?: string;
|
||||
block_height?: number;
|
||||
synced_to_chain?: boolean;
|
||||
testnet?: boolean;
|
||||
chains?: LndChain[];
|
||||
};
|
||||
|
||||
type LndBlockchainBalance = {
|
||||
total_balance?: string;
|
||||
confirmed_balance?: string;
|
||||
unconfirmed_balance?: string;
|
||||
};
|
||||
|
||||
/** lnrpc.Amount — the same sat/msat pair LND uses everywhere it reports a channel-side balance. */
|
||||
type LndAmount = { sat?: string; msat?: string };
|
||||
|
||||
type LndChannelBalance = {
|
||||
balance?: string;
|
||||
local_balance?: LndAmount;
|
||||
remote_balance?: LndAmount;
|
||||
};
|
||||
|
||||
type LndTransaction = {
|
||||
tx_hash?: string;
|
||||
amount?: string;
|
||||
num_confirmations?: number;
|
||||
block_height?: number;
|
||||
time_stamp?: string;
|
||||
total_fees?: string;
|
||||
dest_addresses?: string[];
|
||||
raw_tx_hex?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type LndUtxo = {
|
||||
address_type?: string;
|
||||
address?: string;
|
||||
amount_sat?: string;
|
||||
confirmations?: string;
|
||||
outpoint?: { txid_str?: string; output_index?: number };
|
||||
};
|
||||
|
||||
type LndEstimateFee = { sat_per_kw?: string; min_relay_fee_sat_per_kw?: string };
|
||||
|
||||
type LndInvoice = {
|
||||
memo?: string;
|
||||
r_preimage?: string;
|
||||
r_hash?: string;
|
||||
value?: string;
|
||||
value_msat?: string;
|
||||
creation_date?: string;
|
||||
settle_date?: string;
|
||||
payment_request?: string;
|
||||
expiry?: string;
|
||||
amt_paid_msat?: string;
|
||||
state?: string;
|
||||
is_keysend?: boolean;
|
||||
is_amp?: boolean;
|
||||
};
|
||||
|
||||
type LndAddInvoiceResponse = { r_hash?: string; payment_request?: string; payment_addr?: string };
|
||||
|
||||
type LndFeature = { name?: string; is_required?: boolean; is_known?: boolean };
|
||||
|
||||
type LndPayReq = {
|
||||
destination?: string;
|
||||
payment_hash?: string;
|
||||
num_msat?: string;
|
||||
num_satoshis?: string;
|
||||
timestamp?: string;
|
||||
expiry?: string;
|
||||
description?: string;
|
||||
cltv_expiry?: string;
|
||||
route_hints?: unknown[];
|
||||
features?: Record<string, LndFeature>;
|
||||
};
|
||||
|
||||
type LndHop = { pub_key?: string };
|
||||
type LndHtlcAttempt = { route?: { hops?: LndHop[] } };
|
||||
|
||||
type LndPayment = {
|
||||
payment_hash?: string;
|
||||
payment_preimage?: string;
|
||||
value_msat?: string;
|
||||
fee_msat?: string;
|
||||
status?: string;
|
||||
creation_date?: string;
|
||||
creation_time_ns?: string;
|
||||
failure_reason?: string;
|
||||
htlcs?: LndHtlcAttempt[];
|
||||
};
|
||||
|
||||
/** One newline-delimited frame of the SendPaymentV2 stream. */
|
||||
type LndRouterFrame = { result?: LndPayment; error?: { message?: string; code?: number } };
|
||||
|
||||
type LndChannel = {
|
||||
chan_id?: string;
|
||||
channel_point?: string;
|
||||
remote_pubkey?: string;
|
||||
peer_alias?: string;
|
||||
capacity?: string;
|
||||
local_balance?: string;
|
||||
remote_balance?: string;
|
||||
active?: boolean;
|
||||
private?: boolean;
|
||||
};
|
||||
|
||||
type LndPendingChannel = {
|
||||
remote_node_pub?: string;
|
||||
channel_point?: string;
|
||||
capacity?: string;
|
||||
local_balance?: string;
|
||||
remote_balance?: string;
|
||||
private?: boolean;
|
||||
};
|
||||
|
||||
type LndPendingChannels = {
|
||||
pending_open_channels?: { channel?: LndPendingChannel }[];
|
||||
pending_force_closing_channels?: { channel?: LndPendingChannel }[];
|
||||
waiting_close_channels?: { channel?: LndPendingChannel }[];
|
||||
};
|
||||
|
||||
type LndPeer = { pub_key?: string; address?: string; inbound?: boolean };
|
||||
|
||||
// ── constants ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Zeus's LndUtils.LNRPC_NEW_ADDRESS_TYPE_NAMES, verbatim. See that file for why each row exists. */
|
||||
const LNRPC_NEW_ADDRESS_TYPE_NAMES: Record<string, string> = {
|
||||
'0': 'WITNESS_PUBKEY_HASH',
|
||||
'1': 'NESTED_PUBKEY_HASH',
|
||||
'2': 'UNUSED_WITNESS_PUBKEY_HASH',
|
||||
'3': 'UNUSED_NESTED_PUBKEY_HASH',
|
||||
'4': 'TAPROOT_PUBKEY',
|
||||
'5': 'UNUSED_TAPROOT_PUBKEY',
|
||||
NESTED_WITNESS_PUBKEY_HASH: 'NESTED_PUBKEY_HASH',
|
||||
HYBRID_NESTED_WITNESS_PUBKEY_HASH: 'NESTED_PUBKEY_HASH',
|
||||
};
|
||||
|
||||
/**
|
||||
* Zeus's `toLnrpcAddressType`. The numeric strings matter: LND REST's grpc-gateway silently treats an
|
||||
* unrecognised `type` as WITNESS_PUBKEY_HASH, so sending '1' quietly yields a native segwit address.
|
||||
*/
|
||||
function toLnrpcAddressType(value: string | number | undefined | null): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const key = String(value);
|
||||
return LNRPC_NEW_ADDRESS_TYPE_NAMES[key] ?? key;
|
||||
}
|
||||
|
||||
/** Our AddressType → the lnrpc enum index, in normal and `peek` (UNUSED_*, non-advancing) form. */
|
||||
const ADDRESS_TYPE_INDEX: Record<AddressType, { fresh: string; peek: string } | null> = {
|
||||
p2wpkh: { fresh: '0', peek: '2' },
|
||||
'p2sh-p2wpkh': { fresh: '1', peek: '3' },
|
||||
p2tr: { fresh: '4', peek: '5' },
|
||||
// lnrpc.NewAddress has no legacy p2pkh member — LND has never handed out a base58 receive address.
|
||||
p2pkh: null,
|
||||
};
|
||||
|
||||
/** walletrpc's AddressType enum (ListUnspent) → ours. The HYBRID_ variant is still a p2sh-p2wpkh. */
|
||||
const WALLETRPC_ADDRESS_TYPE: Record<string, AddressType> = {
|
||||
WITNESS_PUBKEY_HASH: 'p2wpkh',
|
||||
NESTED_WITNESS_PUBKEY_HASH: 'p2sh-p2wpkh',
|
||||
HYBRID_NESTED_WITNESS_PUBKEY_HASH: 'p2sh-p2wpkh',
|
||||
TAPROOT_PUBKEY: 'p2tr',
|
||||
};
|
||||
|
||||
/** BOLT spec TLV records for keysend: the preimage the receiver settles with, and the free-text message. */
|
||||
const KEYSEND_PREIMAGE_RECORD = '5482373484';
|
||||
const KEYSEND_MESSAGE_RECORD = '34349334';
|
||||
|
||||
/** A 32-byte all-zero preimage is LND's "not settled yet" placeholder, not a real preimage. */
|
||||
const ZERO_PREIMAGE_HEX = '0'.repeat(64);
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** int64-as-string → number. Used only for sat-denominated fields, which cannot overflow a double. */
|
||||
function toSats(value: string | number | undefined | null): number {
|
||||
if (value == null || value === '') return 0;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** int64-as-string → msat decimal string. Never widened to a number: 2.1e18 does not fit a double. */
|
||||
function toMsat(value: string | number | undefined | null): string {
|
||||
if (value == null || value === '') return '0';
|
||||
return typeof value === 'number' ? Math.round(value).toString() : value;
|
||||
}
|
||||
|
||||
/** `0.18.3-beta commit=v0.18.3-beta` → [0, 18, 3]. Zeus does the same in VersionUtils. */
|
||||
function parseVersion(version: string | null): [number, number, number] | null {
|
||||
if (!version) return null;
|
||||
const m = /(\d+)\.(\d+)\.(\d+)/.exec(version);
|
||||
if (!m) return null;
|
||||
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
||||
}
|
||||
|
||||
/** Unknown version counts as "new enough" — see difference 5 in the header. */
|
||||
function atLeast(version: string | null, min: string): boolean {
|
||||
const have = parseVersion(version);
|
||||
const want = parseVersion(min);
|
||||
if (!have || !want) return true;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const h = have[i] ?? 0;
|
||||
const w = want[i] ?? 0;
|
||||
if (h !== w) return h > w;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toNetwork(info: LndGetInfo): BitcoinNetwork {
|
||||
const raw = info.chains?.[0]?.network ?? (info.testnet ? 'testnet' : 'mainnet');
|
||||
switch (raw) {
|
||||
case 'testnet':
|
||||
case 'testnet3':
|
||||
case 'testnet4':
|
||||
return 'testnet';
|
||||
case 'signet':
|
||||
return 'signet';
|
||||
// simnet is btcd's private-chain mode; it behaves like regtest for everything we surface.
|
||||
case 'regtest':
|
||||
case 'simnet':
|
||||
return 'regtest';
|
||||
default:
|
||||
return 'bitcoin';
|
||||
}
|
||||
}
|
||||
|
||||
function toPaymentStatus(status: string | undefined): PaymentStatus {
|
||||
switch (status) {
|
||||
case 'SUCCEEDED':
|
||||
return 'succeeded';
|
||||
case 'FAILED':
|
||||
return 'failed';
|
||||
// UNKNOWN / IN_FLIGHT / INITIATED all mean "not resolved yet".
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
export type LndConfig = {
|
||||
/** Full base URL including scheme and port, e.g. `https://192.168.1.5:8080`. */
|
||||
url: string;
|
||||
/** Admin (or narrower) macaroon, hex. Sent as `Grpc-Metadata-macaroon`. */
|
||||
macaroonHex: string;
|
||||
allowSelfSigned?: boolean;
|
||||
};
|
||||
|
||||
// ── the backend ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LndBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'lnd';
|
||||
|
||||
// Mutated in place by `applyVersionGates`; `capabilities` is the same object seen read-only.
|
||||
private readonly caps = new Set<Capability>([
|
||||
'onchainReceive',
|
||||
'onchainSend',
|
||||
'coinControl',
|
||||
'psbt',
|
||||
'bumpFee',
|
||||
'sweep',
|
||||
'accounts',
|
||||
'lightningReceive',
|
||||
'lightningSend',
|
||||
'keysend',
|
||||
'customPreimages',
|
||||
'offers',
|
||||
'channels',
|
||||
'peers',
|
||||
'routing',
|
||||
'signMessage',
|
||||
]);
|
||||
protected readonly capabilities: ReadonlySet<Capability> = this.caps;
|
||||
|
||||
constructor(private readonly config: LndConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ── transport ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private get<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<T> {
|
||||
return httpJson<T>({
|
||||
base: this.config.url,
|
||||
path,
|
||||
query,
|
||||
method: 'GET',
|
||||
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
});
|
||||
}
|
||||
|
||||
private post<T>(path: string, body: unknown, timeoutMs?: number): Promise<T> {
|
||||
return httpJson<T>({
|
||||
base: this.config.url,
|
||||
path,
|
||||
method: 'POST',
|
||||
body: body ?? {},
|
||||
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SendPaymentV2 (`/v2/router/send`) is a SERVER-STREAMING rpc. grpc-gateway renders it as
|
||||
* newline-delimited JSON — one `{"result": …}` frame per payment state change — which `JSON.parse` on
|
||||
* the whole body cannot read. Zeus handles this in `restReq` by splitting on '\n' and taking
|
||||
* `split[length - 2]` (the body has a trailing newline); we take the last non-empty line, which is the
|
||||
* same frame without depending on the trailing newline being there.
|
||||
*
|
||||
* We also set `no_inflight_updates`, so in practice there is exactly one frame: the terminal one.
|
||||
*/
|
||||
private async routerSend(body: Record<string, unknown>, timeoutMs: number): Promise<LndPayment> {
|
||||
const res = await httpJson<Response>({
|
||||
base: this.config.url,
|
||||
path: '/v2/router/send',
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { 'Grpc-Metadata-macaroon': this.config.macaroonHex },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
timeoutMs,
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
const lines = text.split('\n').filter((line) => line.trim().length > 0);
|
||||
const last = lines[lines.length - 1];
|
||||
|
||||
if (!last) {
|
||||
throw new BackendError(`router/send returned an empty body (HTTP ${res.status})`, res.ok ? 502 : res.status);
|
||||
}
|
||||
|
||||
let frame: LndRouterFrame;
|
||||
try {
|
||||
frame = JSON.parse(last) as LndRouterFrame;
|
||||
} catch {
|
||||
throw new BackendError(`router/send returned non-JSON: ${last.slice(0, 200)}`, res.ok ? 502 : res.status);
|
||||
}
|
||||
|
||||
// A stream error arrives as a frame, not as a non-2xx status, so check it before `res.ok`.
|
||||
if (frame.error) {
|
||||
throw new BackendError(
|
||||
frame.error.message ?? 'payment failed',
|
||||
res.ok ? 502 : res.status,
|
||||
frame.error.code != null ? String(frame.error.code) : undefined,
|
||||
);
|
||||
}
|
||||
if (!res.ok || !frame.result) {
|
||||
throw new BackendError(`router/send failed: ${last.slice(0, 200)}`, res.ok ? 502 : res.status);
|
||||
}
|
||||
return frame.result;
|
||||
}
|
||||
|
||||
/** Zeus's version predicates, evaluated once per getInfo instead of once per `supportsX()` call. */
|
||||
private applyVersionGates(version: string | null): void {
|
||||
const gate = (cap: Capability, min: string) => {
|
||||
if (atLeast(version, min)) this.caps.add(cap);
|
||||
else this.caps.delete(cap);
|
||||
};
|
||||
gate('coinControl', '0.12.0'); // Zeus: supportsCoinControl
|
||||
gate('accounts', '0.13.0'); // Zeus: supportsAccounts
|
||||
gate('offers', '0.18.0'); // Zeus hardcodes false; see difference 6
|
||||
}
|
||||
|
||||
// ── node / balances ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInfo(): Promise<NodeInfo> {
|
||||
const info = await this.get<LndGetInfo>('/v1/getinfo');
|
||||
const version = info.version ?? null;
|
||||
this.applyVersionGates(version);
|
||||
return {
|
||||
kind: this.kind,
|
||||
pubkey: info.identity_pubkey ?? null,
|
||||
alias: info.alias ?? null,
|
||||
version,
|
||||
network: toNetwork(info),
|
||||
blockHeight: info.block_height ?? null,
|
||||
synced: info.synced_to_chain === true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const [chain, channels] = await Promise.all([
|
||||
this.get<LndBlockchainBalance>('/v1/balance/blockchain'),
|
||||
this.get<LndChannelBalance>('/v1/balance/channels'),
|
||||
]);
|
||||
return {
|
||||
onchainConfirmed: toSats(chain.confirmed_balance),
|
||||
onchainUnconfirmed: toSats(chain.unconfirmed_balance),
|
||||
// `balance` is the deprecated flat field; the nested Amount is authoritative on v0.11+.
|
||||
lightningBalance: toSats(channels.local_balance?.sat ?? channels.balance),
|
||||
lightningInbound: toSats(channels.remote_balance?.sat),
|
||||
};
|
||||
}
|
||||
|
||||
// ── on-chain ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
// `end_height=-1` is LND's "up to and including unconfirmed" sentinel — without it the mempool is
|
||||
// excluded and a just-broadcast send is invisible.
|
||||
const res = await this.get<{ transactions?: LndTransaction[] }>('/v1/transactions', {
|
||||
end_height: -1,
|
||||
max_transactions: opts?.limit ?? 500,
|
||||
});
|
||||
return (res.transactions ?? []).map((tx) => ({
|
||||
txid: tx.tx_hash ?? '',
|
||||
amount: toSats(tx.amount),
|
||||
feeSats: tx.total_fees != null ? toSats(tx.total_fees) : null,
|
||||
blockHeight: tx.block_height ? tx.block_height : null,
|
||||
timestamp: tx.time_stamp ? toSats(tx.time_stamp) : null,
|
||||
confirmations: tx.num_confirmations ?? 0,
|
||||
label: tx.label ? tx.label : null,
|
||||
destAddresses: tx.dest_addresses ?? [],
|
||||
rawHex: tx.raw_tx_hex ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
const type: AddressType = req?.type ?? 'p2wpkh';
|
||||
const index = ADDRESS_TYPE_INDEX[type];
|
||||
if (!index) throw new BackendError(`lnd cannot derive a ${type} address`, 400, 'UNSUPPORTED_ADDRESS_TYPE');
|
||||
|
||||
// `peek` maps onto the UNUSED_* enum members, which return the current address without advancing the
|
||||
// derivation index — LND's only form of "show me the same address again".
|
||||
const lnrpcType = toLnrpcAddressType(req?.peek ? index.peek : index.fresh);
|
||||
const res = await this.get<{ address?: string }>('/v1/newaddress', { type: lnrpcType });
|
||||
if (!res.address) throw new BackendError('lnd returned no address', 502);
|
||||
return { address: res.address, type };
|
||||
}
|
||||
|
||||
override async getUtxos(): Promise<Utxo[]> {
|
||||
// max_confs must be set explicitly: walletrpc defaults it to 0, which matches nothing.
|
||||
const res = await this.post<{ utxos?: LndUtxo[] }>('/v2/wallet/utxos', {
|
||||
min_confs: 0,
|
||||
max_confs: 0x7fffffff,
|
||||
});
|
||||
return (res.utxos ?? []).map((utxo) => ({
|
||||
txid: utxo.outpoint?.txid_str ?? '',
|
||||
vout: utxo.outpoint?.output_index ?? 0,
|
||||
amountSats: toSats(utxo.amount_sat),
|
||||
address: utxo.address ?? '',
|
||||
addressType: utxo.address_type ? (WALLETRPC_ADDRESS_TYPE[utxo.address_type] ?? null) : null,
|
||||
confirmations: toSats(utxo.confirmations),
|
||||
derivationPath: null,
|
||||
// ListUnspent already omits leased outputs, so anything we can see here is selectable.
|
||||
frozen: false,
|
||||
}));
|
||||
}
|
||||
|
||||
override async estimateFees(): Promise<FeeEstimates> {
|
||||
// walletrpc quotes sat/kw (weight units). 1 vB = 4 wu, so sat/vB = sat_per_kw / 250.
|
||||
const perVbyte = (satPerKw: string | undefined): number => Math.max(1, Math.ceil(toSats(satPerKw) / 250));
|
||||
|
||||
const targets = [2, 3, 6, 144] as const;
|
||||
const [fastest, halfHour, hour, economy] = await Promise.all(
|
||||
targets.map((target) => this.get<LndEstimateFee>(`/v2/wallet/estimatefee/${target}`)),
|
||||
);
|
||||
|
||||
return {
|
||||
fastestFee: perVbyte(fastest?.sat_per_kw),
|
||||
halfHourFee: perVbyte(halfHour?.sat_per_kw),
|
||||
hourFee: perVbyte(hour?.sat_per_kw),
|
||||
economyFee: perVbyte(economy?.sat_per_kw),
|
||||
// min_relay_fee_sat_per_kw only exists on v0.18+; 253 sat/kw is bitcoind's floor (~1 sat/vB).
|
||||
minimumFee: perVbyte(fastest?.min_relay_fee_sat_per_kw ?? '253'),
|
||||
};
|
||||
}
|
||||
|
||||
override async sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult> {
|
||||
if (req.outpoints?.length && !this.supports('coinControl')) this.notSupported('coin control');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
addr: req.address,
|
||||
sat_per_vbyte: String(req.satPerVbyte),
|
||||
spend_unconfirmed: req.spendUnconfirmed === true,
|
||||
label: req.label,
|
||||
};
|
||||
if (req.sendAll) body.send_all = true;
|
||||
else body.amount = String(req.amountSats ?? 0);
|
||||
|
||||
if (req.outpoints?.length) {
|
||||
body.outpoints = req.outpoints.map((outpoint) => {
|
||||
const [txid, vout] = outpoint.split(':');
|
||||
return { txid_str: txid, output_index: Number(vout ?? 0) };
|
||||
});
|
||||
}
|
||||
|
||||
const res = await this.post<{ txid?: string }>('/v1/transactions', body);
|
||||
if (!res.txid) throw new BackendError('lnd returned no txid', 502);
|
||||
return { txid: res.txid, feeSats: 0, rawHex: null };
|
||||
}
|
||||
|
||||
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private toInvoice(inv: LndInvoice): Invoice {
|
||||
const createdAt = toSats(inv.creation_date);
|
||||
const expiry = toSats(inv.expiry);
|
||||
const expiresAt = expiry > 0 ? createdAt + expiry : null;
|
||||
const settledAt = toSats(inv.settle_date);
|
||||
const preimageHex = inv.r_preimage ? base64ToHex(inv.r_preimage) : '';
|
||||
|
||||
let state: InvoiceState;
|
||||
switch (inv.state) {
|
||||
case 'SETTLED':
|
||||
state = 'settled';
|
||||
break;
|
||||
case 'CANCELED':
|
||||
state = 'canceled';
|
||||
break;
|
||||
case 'ACCEPTED':
|
||||
state = 'accepted';
|
||||
break;
|
||||
default:
|
||||
// lnrpc has no EXPIRED member; an unsettled invoice past its deadline is reported as OPEN.
|
||||
state = expiresAt != null && expiresAt * 1000 < Date.now() ? 'expired' : 'open';
|
||||
}
|
||||
|
||||
return {
|
||||
paymentHash: inv.r_hash ? base64ToHex(inv.r_hash) : '',
|
||||
bolt11: inv.payment_request ?? '',
|
||||
amountMsat: inv.value_msat && inv.value_msat !== '0' ? toMsat(inv.value_msat) : null,
|
||||
amountPaidMsat: inv.amt_paid_msat && inv.amt_paid_msat !== '0' ? toMsat(inv.amt_paid_msat) : null,
|
||||
memo: inv.memo ? inv.memo : null,
|
||||
state,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
settledAt: settledAt > 0 ? settledAt : null,
|
||||
// LND ships an all-zero r_preimage for anything it has not settled — that is a placeholder, not a
|
||||
// secret, and passing it on would let a caller believe it can claim the HTLC.
|
||||
preimage: preimageHex && preimageHex !== ZERO_PREIMAGE_HEX ? preimageHex : null,
|
||||
isKeysend: inv.is_keysend === true,
|
||||
isAmp: inv.is_amp === true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
// reversed=true walks the add_index backwards, i.e. newest first.
|
||||
const res = await this.get<{ invoices?: LndInvoice[] }>('/v1/invoices', {
|
||||
reversed: true,
|
||||
num_max_invoices: opts?.limit ?? 500,
|
||||
});
|
||||
return (res.invoices ?? []).map((inv) => this.toInvoice(inv));
|
||||
}
|
||||
|
||||
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
const res = await this.post<LndAddInvoiceResponse>('/v1/invoices', {
|
||||
memo: req.memo,
|
||||
value_msat: req.amountMsat,
|
||||
expiry: req.expirySeconds != null ? String(req.expirySeconds) : undefined,
|
||||
is_amp: req.isAmp,
|
||||
private: req.private,
|
||||
r_preimage: req.preimage ? hexToBase64(req.preimage) : undefined,
|
||||
});
|
||||
|
||||
const paymentHash = res.r_hash ? base64ToHex(res.r_hash) : '';
|
||||
|
||||
// AddInvoiceResponse carries only the hash, the request string and the payment address — no
|
||||
// timestamps and no state — so read the invoice back to answer with a complete record.
|
||||
const stored = paymentHash ? await this.lookupInvoice(paymentHash).catch(() => null) : null;
|
||||
if (stored) return stored;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
paymentHash,
|
||||
bolt11: res.payment_request ?? '',
|
||||
amountMsat: req.amountMsat ?? null,
|
||||
amountPaidMsat: null,
|
||||
memo: req.memo ?? null,
|
||||
state: 'open',
|
||||
createdAt: now,
|
||||
expiresAt: req.expirySeconds != null ? now + req.expirySeconds : null,
|
||||
settledAt: null,
|
||||
preimage: null,
|
||||
isKeysend: false,
|
||||
isAmp: req.isAmp === true,
|
||||
};
|
||||
}
|
||||
|
||||
override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
try {
|
||||
// The path segment is `r_hash_str` — hex, not the base64 used by the body fields.
|
||||
const inv = await this.get<LndInvoice>(`/v1/invoice/${paymentHash}`);
|
||||
return this.toInvoice(inv);
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
|
||||
const res = await this.get<LndPayReq>(`/v1/payreq/${encodeURIComponent(bolt11)}`);
|
||||
return {
|
||||
bolt11,
|
||||
// PayReq.payment_hash is a proto `string` (already hex), unlike Invoice.r_hash which is `bytes`.
|
||||
paymentHash: res.payment_hash ?? '',
|
||||
amountMsat: res.num_msat && res.num_msat !== '0' ? toMsat(res.num_msat) : null,
|
||||
description: res.description ? res.description : null,
|
||||
destination: res.destination ?? '',
|
||||
timestamp: toSats(res.timestamp),
|
||||
expiry: toSats(res.expiry),
|
||||
cltvExpiry: res.cltv_expiry != null ? toSats(res.cltv_expiry) : null,
|
||||
routeHints: (res.route_hints ?? []).length > 0,
|
||||
features: Object.values(res.features ?? {})
|
||||
.map((feature) => feature.name)
|
||||
.filter((name): name is string => !!name),
|
||||
};
|
||||
}
|
||||
|
||||
private toPayment(payment: LndPayment): Payment {
|
||||
const preimage = payment.payment_preimage ?? '';
|
||||
// creation_date (seconds) was deprecated in favour of creation_time_ns; accept either.
|
||||
const createdAt = payment.creation_date
|
||||
? toSats(payment.creation_date)
|
||||
: Math.floor(toSats(payment.creation_time_ns) / 1e9);
|
||||
const hops = payment.htlcs?.[0]?.route?.hops ?? [];
|
||||
|
||||
return {
|
||||
// ListPayments renders payment_hash/payment_preimage as proto `string`s — already hex.
|
||||
paymentHash: payment.payment_hash ?? '',
|
||||
preimage: preimage && preimage !== ZERO_PREIMAGE_HEX ? preimage : null,
|
||||
amountMsat: toMsat(payment.value_msat),
|
||||
feeMsat: toMsat(payment.fee_msat),
|
||||
status: toPaymentStatus(payment.status),
|
||||
createdAt,
|
||||
// lnrpc.Payment has no destination field; the last hop of the first attempt's route is the payee.
|
||||
destination: hops[hops.length - 1]?.pub_key ?? null,
|
||||
// Recovering the memo would mean decoding payment_request on every row.
|
||||
memo: null,
|
||||
failureReason:
|
||||
payment.failure_reason && payment.failure_reason !== 'FAILURE_REASON_NONE' ? payment.failure_reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
|
||||
const res = await this.get<{ payments?: LndPayment[] }>('/v1/payments', {
|
||||
include_incomplete: true,
|
||||
max_payments: opts?.limit ?? 500,
|
||||
reversed: true,
|
||||
});
|
||||
return (res.payments ?? []).map((payment) => this.toPayment(payment));
|
||||
}
|
||||
|
||||
override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
|
||||
if (req.feeLimitMsat && req.feeLimitPercent != null) {
|
||||
throw new BackendError('feeLimitMsat and feeLimitPercent are mutually exclusive', 400);
|
||||
}
|
||||
|
||||
const timeoutSeconds = req.timeoutSeconds ?? 60;
|
||||
const body: Record<string, unknown> = {
|
||||
payment_request: req.bolt11,
|
||||
timeout_seconds: timeoutSeconds,
|
||||
// Zeus sets this so a payment to one's own node is not rejected as a self-payment.
|
||||
allow_self_payment: true,
|
||||
// Collapses the stream to a single terminal frame — see `routerSend`.
|
||||
no_inflight_updates: true,
|
||||
};
|
||||
if (req.amountMsat) body.amt_msat = req.amountMsat;
|
||||
|
||||
if (req.feeLimitMsat) {
|
||||
body.fee_limit_msat = req.feeLimitMsat;
|
||||
} else if (req.feeLimitPercent != null) {
|
||||
// SendPaymentRequest has no percentage form (that is a CLN concept), so resolve the invoice amount
|
||||
// and turn the percentage into the absolute cap LND wants.
|
||||
const amountMsat = req.amountMsat ?? (await this.decodeInvoice(req.bolt11)).amountMsat;
|
||||
if (!amountMsat) {
|
||||
throw new BackendError('feeLimitPercent needs an amount: the invoice is zero-amount', 400);
|
||||
}
|
||||
const limit = (BigInt(amountMsat) * BigInt(Math.round(req.feeLimitPercent * 100))) / 10_000n;
|
||||
body.fee_limit_msat = limit.toString();
|
||||
}
|
||||
|
||||
// Give the HTTP call a little more room than LND's own deadline so the node, not the socket, decides.
|
||||
const result = await this.routerSend(body, (timeoutSeconds + 10) * 1000);
|
||||
return this.toPayment(result);
|
||||
}
|
||||
|
||||
override async sendKeysend(req: KeysendRequest): Promise<Payment> {
|
||||
// Keysend is a spontaneous payment: WE pick the preimage, hash it ourselves, and ship the preimage to
|
||||
// the receiver in TLV 5482373484 so it can settle an HTLC it never issued an invoice for.
|
||||
const preimage = randomBytes(32);
|
||||
const paymentHash = createHash('sha256').update(preimage).digest();
|
||||
|
||||
const customRecords: Record<string, string> = {
|
||||
[KEYSEND_PREIMAGE_RECORD]: preimage.toString('base64'),
|
||||
};
|
||||
if (req.message) customRecords[KEYSEND_MESSAGE_RECORD] = Buffer.from(req.message, 'utf8').toString('base64');
|
||||
|
||||
const timeoutSeconds = 60;
|
||||
const body: Record<string, unknown> = {
|
||||
dest: hexToBase64(req.destination),
|
||||
amt_msat: req.amountMsat,
|
||||
payment_hash: paymentHash.toString('base64'),
|
||||
dest_custom_records: customRecords,
|
||||
// Without a route hint there is no invoice to read a final CLTV delta from; 40 is LND's own default.
|
||||
final_cltv_delta: 40,
|
||||
timeout_seconds: timeoutSeconds,
|
||||
allow_self_payment: true,
|
||||
no_inflight_updates: true,
|
||||
};
|
||||
if (req.feeLimitMsat) body.fee_limit_msat = req.feeLimitMsat;
|
||||
|
||||
const result = await this.routerSend(body, (timeoutSeconds + 10) * 1000);
|
||||
return this.toPayment(result);
|
||||
}
|
||||
|
||||
// ── channels / peers ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getChannels(): Promise<Channel[]> {
|
||||
const [open, pending] = await Promise.all([
|
||||
// peer_alias_lookup makes LND resolve each peer's graph alias for us (v0.15.1+); on older nodes the
|
||||
// parameter is ignored and `peer_alias` simply comes back absent.
|
||||
this.get<{ channels?: LndChannel[] }>('/v1/channels', { peer_alias_lookup: true }),
|
||||
this.get<LndPendingChannels>('/v1/channels/pending'),
|
||||
]);
|
||||
|
||||
const channels: Channel[] = (open.channels ?? []).map((chan) => ({
|
||||
channelId: chan.chan_id ?? chan.channel_point ?? '',
|
||||
channelPoint: chan.channel_point ?? null,
|
||||
remotePubkey: chan.remote_pubkey ?? '',
|
||||
remoteAlias: chan.peer_alias ? chan.peer_alias : null,
|
||||
capacitySats: toSats(chan.capacity),
|
||||
localBalanceSats: toSats(chan.local_balance),
|
||||
remoteBalanceSats: toSats(chan.remote_balance),
|
||||
active: chan.active === true,
|
||||
private: chan.private === true,
|
||||
status: 'open',
|
||||
}));
|
||||
|
||||
const fromPending = (entries: { channel?: LndPendingChannel }[] | undefined, status: string): Channel[] =>
|
||||
(entries ?? [])
|
||||
.map((entry) => entry.channel)
|
||||
.filter((chan): chan is LndPendingChannel => !!chan)
|
||||
.map((chan) => ({
|
||||
// A pending channel has no short channel id yet — the funding outpoint is its only identity.
|
||||
channelId: chan.channel_point ?? '',
|
||||
channelPoint: chan.channel_point ?? null,
|
||||
remotePubkey: chan.remote_node_pub ?? '',
|
||||
remoteAlias: null,
|
||||
capacitySats: toSats(chan.capacity),
|
||||
localBalanceSats: toSats(chan.local_balance),
|
||||
remoteBalanceSats: toSats(chan.remote_balance),
|
||||
active: false,
|
||||
private: chan.private === true,
|
||||
status,
|
||||
}));
|
||||
|
||||
return [
|
||||
...channels,
|
||||
...fromPending(pending.pending_open_channels, 'pending-open'),
|
||||
// "waiting close" is a cooperative close whose closing tx has not confirmed; force-closing is the
|
||||
// unilateral path with a timelock still to run.
|
||||
...fromPending(pending.waiting_close_channels, 'pending-close'),
|
||||
...fromPending(pending.pending_force_closing_channels, 'force-closing'),
|
||||
];
|
||||
}
|
||||
|
||||
override async getPeers(): Promise<Peer[]> {
|
||||
const res = await this.get<{ peers?: LndPeer[] }>('/v1/peers');
|
||||
return (res.peers ?? []).map((peer) => ({
|
||||
pubkey: peer.pub_key ?? '',
|
||||
address: peer.address ?? '',
|
||||
alias: null,
|
||||
inbound: peer.inbound === true,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── signing ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async signMessage(message: string): Promise<SignMessageResult> {
|
||||
// lnrpc.SignMessageRequest.msg is `bytes`, so the payload is base64 even though it is plain text.
|
||||
const res = await this.post<{ signature?: string }>('/v1/signmessage', {
|
||||
msg: Buffer.from(message, 'utf8').toString('base64'),
|
||||
});
|
||||
if (!res.signature) throw new BackendError('lnd returned no signature', 502);
|
||||
return { signature: res.signature };
|
||||
}
|
||||
|
||||
override async verifyMessage(message: string, signature: string): Promise<VerifyMessageResult> {
|
||||
const res = await this.post<{ valid?: boolean; pubkey?: string }>('/v1/verifymessage', {
|
||||
msg: Buffer.from(message, 'utf8').toString('base64'),
|
||||
signature,
|
||||
});
|
||||
return { valid: res.valid === true, pubkey: res.pubkey ?? null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
// LNDHub — port of Zeus's backends/LndHub.ts (which `extends LND` and overrides ~10 methods).
|
||||
//
|
||||
// LNDHub is a CUSTODIAL account API, not a node. The server holds the keys; this backend is a thin
|
||||
// client over a REST facade (BlueWallet/LndHub, LNbits' lndhub extension, Alby, lntxbot, …). There is
|
||||
// no node identity, no channel view, no UTXO set and no on-chain spend — only a balance, invoices,
|
||||
// payments and (on some deployments) a single deposit address.
|
||||
//
|
||||
// Differences from Zeus, all deliberate:
|
||||
//
|
||||
// 1. AUTH. Zeus logs in from SettingsStore (SettingsStore.ts:2090-2130), parks `access_token` in an
|
||||
// observable and then never refreshes it — an expired token surfaces as a "bad auth" error string
|
||||
// in the UI. Here the token is cached in memory, acquired lazily, and re-acquired transparently on
|
||||
// the first auth failure of any request, through a single-flight promise so N concurrent calls
|
||||
// produce one login rather than N.
|
||||
// 2. ERRORS. LNDHub reports failure as **HTTP 200 with `{error, code, message}`** (Zeus checks for
|
||||
// this ad hoc in three different stores: InvoicesStore.ts:410, TransactionsStore.ts:795). httpJson
|
||||
// only maps non-2xx, so every response goes through `unwrap()` first. Error code 1 ("bad auth") is
|
||||
// re-raised as a 401 so it feeds the same re-auth path as a real 401.
|
||||
// 3. lookupInvoice / getTransactions. Zeus *inherits* LND's `/v1/invoice/:r_hash` and
|
||||
// `/v1/transactions`, neither of which exists on an LNDHub server — those calls simply fail
|
||||
// upstream. lookupInvoice here scans `/getuserinvoices`; on-chain history is read out of the
|
||||
// `bitcoind_tx` entries that `/gettxs` interleaves with lightning payments.
|
||||
// 4. decodeInvoice. Zeus decodes BOLT11 locally (Bolt11Utils). The sidecar has no bolt11 decoder, so
|
||||
// this uses the server's own `/decodeinvoice`, which returns LND's payreq shape.
|
||||
// 5. The `lnurlAuth` signing modes (Alby vs BlueWallet key derivation) are not ported — LNURL-auth is
|
||||
// not part of the WalletBackend contract.
|
||||
//
|
||||
// UNITS. LNDHub is sloppy here and each field has to be taken on its own terms:
|
||||
// • /balance → BTC.AvailableBalance, satoshis
|
||||
// • /getuserinvoices → `amt`, satoshis
|
||||
// • /gettxs (payment) → `value` and `fee`, satoshis, where `value` already includes `fee`
|
||||
// • /gettxs (onchain) → `amount`, **BTC** as a float (verbatim from bitcoind's listtransactions)
|
||||
// • /decodeinvoice → `num_satoshis` and `num_msat` side by side
|
||||
// Everything is normalised to the contract's sats-as-number / msats-as-decimal-string rule on the way out.
|
||||
|
||||
import {
|
||||
BackendError,
|
||||
type AddressType,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type Capability,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type Invoice,
|
||||
type InvoiceState,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type BitcoinNetwork,
|
||||
} from '../types';
|
||||
import { BaseBackend, base64ToHex, httpJson } from './base';
|
||||
import { decodeBolt11 } from '../bolt11';
|
||||
|
||||
export type LndHubConfig = {
|
||||
/** Base URL of the LNDHub server, e.g. https://lndhub.example.com or https://ln.getalby.com/lndhub. */
|
||||
url: string;
|
||||
login: string;
|
||||
password: string;
|
||||
/** Opt-in TLS relaxation for a self-hosted server with a self-signed cert. Never default. */
|
||||
allowSelfSigned?: boolean;
|
||||
};
|
||||
|
||||
// ── upstream wire shapes ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every LNDHub response can be this instead of the documented shape, with HTTP 200. */
|
||||
type LndHubErrorBody = { error?: string | boolean | number; code?: number; message?: string };
|
||||
|
||||
/** node's `JSON.stringify(Buffer)` — LNDHub leaks raw Buffers for hash/preimage fields. */
|
||||
type LndHubBuffer = { type?: string; data?: number[] };
|
||||
type LndHubBytes = string | LndHubBuffer;
|
||||
|
||||
type LndHubAuth = { access_token?: string; refresh_token?: string };
|
||||
|
||||
type LndHubBalance = { BTC?: { AvailableBalance?: number; TotalBalance?: number } };
|
||||
|
||||
type LndHubGetInfo = {
|
||||
identity_pubkey?: string;
|
||||
alias?: string;
|
||||
version?: string;
|
||||
block_height?: number;
|
||||
testnet?: boolean;
|
||||
synced_to_chain?: boolean;
|
||||
chains?: { chain?: string; network?: string }[];
|
||||
};
|
||||
|
||||
type LndHubUserInvoice = {
|
||||
payment_request?: string;
|
||||
pay_req?: string;
|
||||
r_hash?: LndHubBytes;
|
||||
payment_hash?: LndHubBytes;
|
||||
description?: string;
|
||||
memo?: string;
|
||||
ispaid?: boolean;
|
||||
/** satoshis */
|
||||
amt?: number | string;
|
||||
amt_paid_sat?: number | string;
|
||||
amt_paid_msat?: number | string;
|
||||
/** seconds of validity, relative to `timestamp` */
|
||||
expire_time?: number;
|
||||
/** unix seconds */
|
||||
timestamp?: number | string;
|
||||
settled_at?: number;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
/** `/gettxs` interleaves outgoing lightning payments and imported on-chain deposits. */
|
||||
type LndHubTx = {
|
||||
type?: string;
|
||||
// paid_invoice
|
||||
payment_preimage?: LndHubBytes;
|
||||
payment_hash?: LndHubBytes;
|
||||
payment_request?: string;
|
||||
/** satoshis, and LNDHub folds `fee` into it (User.getTxs) */
|
||||
value?: number | string;
|
||||
/** satoshis */
|
||||
fee?: number | string;
|
||||
memo?: string;
|
||||
description?: string;
|
||||
timestamp?: number | string;
|
||||
// bitcoind_tx
|
||||
txid?: string;
|
||||
/** BTC, float, straight from bitcoind */
|
||||
amount?: number;
|
||||
confirmations?: number;
|
||||
address?: string;
|
||||
category?: string;
|
||||
time?: number;
|
||||
blockheight?: number;
|
||||
};
|
||||
|
||||
type LndHubAddress = { address?: string };
|
||||
|
||||
type LndHubDecoded = {
|
||||
destination?: string;
|
||||
payment_hash?: string;
|
||||
num_satoshis?: string | number;
|
||||
num_msat?: string | number;
|
||||
timestamp?: string | number;
|
||||
expiry?: string | number;
|
||||
description?: string;
|
||||
cltv_expiry?: string | number;
|
||||
route_hints?: unknown[];
|
||||
features?: Record<string, { name?: string }>;
|
||||
};
|
||||
|
||||
/** `/payinvoice` passes LND's sendPaymentSync response through, mostly. */
|
||||
type LndHubPayResult = {
|
||||
payment_error?: string;
|
||||
payment_preimage?: LndHubBytes;
|
||||
payment_hash?: LndHubBytes;
|
||||
payment_route?: {
|
||||
total_amt?: number | string;
|
||||
total_fees?: number | string;
|
||||
total_amt_msat?: number | string;
|
||||
total_fees_msat?: number | string;
|
||||
};
|
||||
decoded?: LndHubDecoded;
|
||||
/** some forks answer a zero-amount pay with the amount they chose */
|
||||
num_satoshis?: number | string;
|
||||
};
|
||||
|
||||
type LndHubCall = {
|
||||
path: string;
|
||||
method?: 'GET' | 'POST';
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
};
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HEX32 = /^[0-9a-f]{64}$/i;
|
||||
|
||||
/** LNDHub returns a 32-byte field as hex, base64, url-safe base64 or a stringified Buffer. */
|
||||
function bytesToHex(value: LndHubBytes | undefined | null): string {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') {
|
||||
if (HEX32.test(value)) return value.toLowerCase();
|
||||
return base64ToHex(value.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
}
|
||||
if (Array.isArray(value.data)) return Buffer.from(value.data).toString('hex');
|
||||
return '';
|
||||
}
|
||||
|
||||
const num = (value: number | string | undefined | null): number => {
|
||||
const n = typeof value === 'string' ? Number(value) : value;
|
||||
return typeof n === 'number' && Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
|
||||
const satsToMsat = (sats: number): string => (BigInt(Math.round(sats)) * 1000n).toString();
|
||||
|
||||
/** msat string → whole satoshis. LNDHub cannot express sub-satoshi amounts anywhere. */
|
||||
function msatToSats(msat: string, field: string): number {
|
||||
const value = BigInt(msat);
|
||||
if (value % 1000n !== 0n) {
|
||||
throw new BackendError(`lndhub cannot express sub-satoshi amounts (${field}=${msat}msat)`, 400, 'BAD_AMOUNT');
|
||||
}
|
||||
return Number(value / 1000n);
|
||||
}
|
||||
|
||||
/** LNDHub tells us nothing about the address it hands out, so classify it by prefix. */
|
||||
function addressType(address: string): AddressType {
|
||||
const a = address.toLowerCase();
|
||||
if (a.startsWith('bc1p') || a.startsWith('tb1p') || a.startsWith('bcrt1p')) return 'p2tr';
|
||||
if (a.startsWith('bc1') || a.startsWith('tb1') || a.startsWith('bcrt1')) return 'p2wpkh';
|
||||
if (a.startsWith('3') || a.startsWith('2')) return 'p2sh-p2wpkh';
|
||||
return 'p2pkh';
|
||||
}
|
||||
|
||||
// ── backend ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class LndHubBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'lndhub';
|
||||
|
||||
// Custodial: receive and send over lightning, plus a deposit address on deployments that expose
|
||||
// /getbtc. No on-chain send, no coin control, no PSBT, no channels, no peers, no message signing —
|
||||
// the wallet does not hold the keys those would need.
|
||||
protected readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
|
||||
'lightningReceive',
|
||||
'lightningSend',
|
||||
'onchainReceive',
|
||||
]);
|
||||
|
||||
private accessToken: string | null = null;
|
||||
/** In-flight login, shared by every caller that needs a token — the single-flight refresh. */
|
||||
private pendingLogin: Promise<string> | null = null;
|
||||
|
||||
constructor(private readonly config: LndHubConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ── auth ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `POST /auth?type=auth {login, password}` → `{access_token, refresh_token}`. The refresh_token
|
||||
* grant (`type=refresh_token`) is deliberately not used: we hold the password, so a fresh login is
|
||||
* one round-trip either way and has one failure mode instead of two.
|
||||
*/
|
||||
private async login(): Promise<string> {
|
||||
const res = await httpJson<LndHubAuth & LndHubErrorBody>({
|
||||
base: this.config.url,
|
||||
path: '/auth',
|
||||
method: 'POST',
|
||||
query: { type: 'auth' },
|
||||
body: { login: this.config.login, password: this.config.password },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
});
|
||||
if (res?.error || !res?.access_token) {
|
||||
const message = res?.message ?? (typeof res?.error === 'string' ? res.error : 'lndhub rejected the login');
|
||||
throw new BackendError(message, 401, 'LNDHUB_AUTH_FAILED');
|
||||
}
|
||||
this.accessToken = res.access_token;
|
||||
return res.access_token;
|
||||
}
|
||||
|
||||
private async token(force = false): Promise<string> {
|
||||
if (!force && this.accessToken) return this.accessToken;
|
||||
// Concurrent 401s all await the same login; whoever loses the race gets the winner's token.
|
||||
this.pendingLogin ??= this.login().finally(() => {
|
||||
this.pendingLogin = null;
|
||||
});
|
||||
return this.pendingLogin;
|
||||
}
|
||||
|
||||
/** LNDHub's 200-with-`{error}` channel, folded back into the exception path. */
|
||||
private unwrap<T>(payload: T): T {
|
||||
const body = payload as LndHubErrorBody | null;
|
||||
if (body && typeof body === 'object' && !Array.isArray(body) && body.error) {
|
||||
const message = body.message || (typeof body.error === 'string' ? body.error : 'lndhub returned an error');
|
||||
// code 1 is LNDHub's "bad auth"; some forks only put the phrase in the message.
|
||||
if (body.code === 1 || /bad auth/i.test(message)) {
|
||||
throw new BackendError(message, 401, 'LNDHUB_BAD_AUTH');
|
||||
}
|
||||
throw new BackendError(message, 502, body.code != null ? `LNDHUB_${body.code}` : undefined);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async call<T>(req: LndHubCall): Promise<T> {
|
||||
const send = async (token: string): Promise<T> =>
|
||||
this.unwrap(
|
||||
await httpJson<T>({
|
||||
base: this.config.url,
|
||||
path: req.path,
|
||||
method: req.method ?? 'GET',
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
allowSelfSigned: this.config.allowSelfSigned,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
return await send(await this.token());
|
||||
} catch (err) {
|
||||
// One retry, and only for auth — a second 401 with a token minted seconds ago is a real failure.
|
||||
if (!(err instanceof BackendError) || err.status !== 401) throw err;
|
||||
this.accessToken = null;
|
||||
return send(await this.token(true));
|
||||
}
|
||||
}
|
||||
|
||||
// ── node / balances ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Best-effort. `/getinfo` exists on BlueWallet-derived servers but not on every fork (Alby, LNbits),
|
||||
* so a failure degrades to an anonymous descriptor rather than breaking connect. Zeus sidesteps this
|
||||
* by reporting `supportsNodeInfo() = false` and never calling it.
|
||||
*/
|
||||
override async getInfo(): Promise<NodeInfo> {
|
||||
let info: LndHubGetInfo | null = null;
|
||||
try {
|
||||
info = await this.call<LndHubGetInfo>({ path: '/getinfo' });
|
||||
} catch {
|
||||
info = null;
|
||||
}
|
||||
|
||||
const chain = info?.chains?.[0]?.network;
|
||||
const network: BitcoinNetwork =
|
||||
chain === 'testnet' || chain === 'signet' || chain === 'regtest'
|
||||
? chain
|
||||
: info?.testnet === true
|
||||
? 'testnet'
|
||||
: 'bitcoin';
|
||||
|
||||
return {
|
||||
kind: this.kind,
|
||||
// The account is custodial — the pubkey, if any, belongs to the operator's node, not to us.
|
||||
pubkey: info?.identity_pubkey ?? null,
|
||||
alias: info?.alias ?? null,
|
||||
version: info?.version ?? null,
|
||||
network,
|
||||
blockHeight: info?.block_height ?? null,
|
||||
synced: info?.synced_to_chain ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const res = await this.call<LndHubBalance>({ path: '/balance' });
|
||||
return {
|
||||
// A custodial account has no on-chain balance of its own; deposits land in the lightning balance.
|
||||
onchainConfirmed: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
lightningBalance: num(res?.BTC?.AvailableBalance),
|
||||
// Inbound liquidity is the operator's problem and is never reported.
|
||||
lightningInbound: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── on-chain ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `/gettxs` mixes `paid_invoice` (lightning, outgoing) with `bitcoind_tx` (on-chain deposits copied
|
||||
* out of bitcoind's listtransactions). Only the latter belong here.
|
||||
*/
|
||||
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
const txs = await this.fetchTxs(opts?.limit);
|
||||
return txs
|
||||
.filter((tx) => tx.type === 'bitcoind_tx' || (!!tx.txid && tx.payment_preimage === undefined))
|
||||
.map((tx) => ({
|
||||
txid: tx.txid ?? '',
|
||||
// `amount` here is BTC as a float, not sats. Round after scaling — 0.1 BTC is not exact.
|
||||
amount: Math.round(num(tx.amount) * 1e8),
|
||||
feeSats: null,
|
||||
blockHeight: tx.blockheight ?? null,
|
||||
timestamp: tx.time ?? (tx.timestamp != null ? num(tx.timestamp) : null),
|
||||
confirmations: tx.confirmations ?? 0,
|
||||
label: null,
|
||||
destAddresses: tx.address ? [tx.address] : [],
|
||||
rawHex: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* `/getbtc` returns the account's single deposit address as a one-element array (Zeus reads
|
||||
* `data[0].address`, InvoicesStore.ts:620). It is static per account, so `peek` is meaningless and
|
||||
* the requested address type is ignored — the operator chose it. On the deployments that hand back an
|
||||
* empty array until an address has been allocated, `/newbtc` allocates one.
|
||||
*/
|
||||
override async getNewAddress(_req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
let address = await this.fetchBtcAddress();
|
||||
if (!address) {
|
||||
await this.call<unknown>({ path: '/newbtc', method: 'POST', body: {} });
|
||||
address = await this.fetchBtcAddress();
|
||||
}
|
||||
if (!address) {
|
||||
throw new BackendError('lndhub server does not offer on-chain deposits', 501, 'NOT_SUPPORTED');
|
||||
}
|
||||
return { address, type: addressType(address) };
|
||||
}
|
||||
|
||||
private async fetchBtcAddress(): Promise<string> {
|
||||
const res = await this.call<LndHubAddress[] | LndHubAddress>({ path: '/getbtc' });
|
||||
if (Array.isArray(res)) return res[0]?.address ?? '';
|
||||
return res?.address ?? '';
|
||||
}
|
||||
|
||||
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
const res = await this.call<LndHubUserInvoice[]>({
|
||||
path: '/getuserinvoices',
|
||||
query: { limit: opts?.limit },
|
||||
});
|
||||
return (Array.isArray(res) ? res : []).map((inv) => this.toInvoice(inv));
|
||||
}
|
||||
|
||||
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
if (req.preimage) return this.notSupported('custom preimages');
|
||||
if (req.isAmp) return this.notSupported('AMP invoices');
|
||||
// LNDHub answers a zero-amount invoice request with "Bad arguments" (Zeus special-cases the string
|
||||
// in InvoicesStore.ts:425); reject it here with something legible instead.
|
||||
if (!req.amountMsat || req.amountMsat === '0') {
|
||||
throw new BackendError('lndhub requires an invoice amount', 400, 'AMOUNT_REQUIRED');
|
||||
}
|
||||
const sats = msatToSats(req.amountMsat, 'amountMsat');
|
||||
// `expirySeconds` and `private` have no equivalent — the server picks both (Zeus:
|
||||
// supportsSettingInvoiceExpiration() = false).
|
||||
const res = await this.call<LndHubUserInvoice>({
|
||||
path: '/addinvoice',
|
||||
method: 'POST',
|
||||
body: { amt: String(sats), memo: req.memo ?? '' },
|
||||
});
|
||||
|
||||
const bolt11 = res.payment_request ?? res.pay_req ?? '';
|
||||
let paymentHash = bytesToHex(res.r_hash ?? res.payment_hash);
|
||||
// A few forks omit r_hash on /addinvoice. The hash is already in the invoice we were just handed, so
|
||||
// read it locally rather than spending a second authenticated round-trip — and a second failure mode
|
||||
// — on `/decodeinvoice`, which those same forks are the least likely to implement.
|
||||
if (!paymentHash && bolt11) {
|
||||
paymentHash = decodeBolt11(bolt11).paymentHash;
|
||||
}
|
||||
|
||||
const createdAt = res.timestamp != null ? num(res.timestamp) : Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
paymentHash,
|
||||
bolt11,
|
||||
amountMsat: satsToMsat(sats),
|
||||
amountPaidMsat: null,
|
||||
memo: req.memo ?? res.description ?? null,
|
||||
state: 'open',
|
||||
createdAt,
|
||||
expiresAt: res.expire_time ? createdAt + res.expire_time : null,
|
||||
settledAt: null,
|
||||
preimage: null,
|
||||
isKeysend: false,
|
||||
isAmp: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LNDHub has no lookup endpoint — Zeus inherits LND's `/v1/invoice/:r_hash`, which 404s here. Scan
|
||||
* the invoice list instead. Bounded at 200 entries: an older invoice reports as not found rather
|
||||
* than paging the whole history on every poll.
|
||||
*/
|
||||
override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
const wanted = paymentHash.toLowerCase();
|
||||
const res = await this.call<LndHubUserInvoice[]>({ path: '/getuserinvoices', query: { limit: 200 } });
|
||||
const found = (Array.isArray(res) ? res : []).find((inv) => bytesToHex(inv.r_hash ?? inv.payment_hash) === wanted);
|
||||
return found ? this.toInvoice(found) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeus decodes BOLT11 client-side (Bolt11Utils) and never calls this endpoint. The sidecar has no
|
||||
* decoder, and the server's `/decodeinvoice?invoice=` answers with LND's payreq shape, which maps
|
||||
* onto DecodedInvoice one-for-one.
|
||||
*/
|
||||
override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
|
||||
const res = await this.call<LndHubDecoded>({ path: '/decodeinvoice', query: { invoice: bolt11 } });
|
||||
const msat = res.num_msat != null ? String(num(res.num_msat)) : null;
|
||||
return {
|
||||
bolt11,
|
||||
paymentHash: res.payment_hash ?? '',
|
||||
amountMsat: msat ?? (res.num_satoshis != null ? satsToMsat(num(res.num_satoshis)) : null),
|
||||
description: res.description ?? null,
|
||||
destination: res.destination ?? '',
|
||||
timestamp: num(res.timestamp),
|
||||
expiry: res.expiry != null ? num(res.expiry) : 3600,
|
||||
cltvExpiry: res.cltv_expiry != null ? num(res.cltv_expiry) : null,
|
||||
routeHints: Array.isArray(res.route_hints) && res.route_hints.length > 0,
|
||||
features: Object.values(res.features ?? {}).map((f) => f?.name ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
|
||||
const txs = await this.fetchTxs(opts?.limit);
|
||||
return txs
|
||||
.filter((tx) => tx.type === 'paid_invoice' || tx.payment_preimage !== undefined)
|
||||
.map((tx) => {
|
||||
// LNDHub's User.getTxs() sets `value = payment_route.total_amt + payment_route.total_fees`,
|
||||
// so the destination amount is `value - fee`. Both are satoshis.
|
||||
const feeSats = num(tx.fee);
|
||||
const amountSats = Math.max(num(tx.value) - feeSats, 0);
|
||||
return {
|
||||
paymentHash: bytesToHex(tx.payment_hash),
|
||||
preimage: bytesToHex(tx.payment_preimage) || null,
|
||||
amountMsat: satsToMsat(amountSats),
|
||||
feeMsat: satsToMsat(feeSats),
|
||||
// /gettxs only records payments that went through; failures are never persisted.
|
||||
status: 'succeeded' as const,
|
||||
createdAt: num(tx.timestamp ?? tx.time),
|
||||
destination: null,
|
||||
memo: tx.memo ?? tx.description ?? null,
|
||||
failureReason: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /payinvoice {invoice, amount}` — `amount` is satoshis and only consulted for a zero-amount
|
||||
* invoice. Fee limits and timeouts are the operator's policy (Zeus: supportsCustomFeeLimit() =
|
||||
* false), so feeLimitMsat / feeLimitPercent / timeoutSeconds are accepted and ignored.
|
||||
*/
|
||||
override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
|
||||
const amountSats = req.amountMsat ? msatToSats(req.amountMsat, 'amountMsat') : undefined;
|
||||
const res = await this.call<LndHubPayResult>({
|
||||
path: '/payinvoice',
|
||||
method: 'POST',
|
||||
body: { invoice: req.bolt11, amount: amountSats },
|
||||
});
|
||||
|
||||
const route = res.payment_route ?? {};
|
||||
const feeMsat =
|
||||
route.total_fees_msat != null ? String(num(route.total_fees_msat)) : satsToMsat(num(route.total_fees));
|
||||
// Prefer the invoice's own amount: `total_amt` is ambiguous across forks about whether fees are
|
||||
// included, whereas the decoded payreq is not.
|
||||
const decodedMsat =
|
||||
res.decoded?.num_msat != null
|
||||
? String(num(res.decoded.num_msat))
|
||||
: res.decoded?.num_satoshis != null
|
||||
? satsToMsat(num(res.decoded.num_satoshis))
|
||||
: null;
|
||||
const routedMsat =
|
||||
route.total_amt_msat != null ? String(num(route.total_amt_msat)) : satsToMsat(num(route.total_amt));
|
||||
|
||||
const preimage = bytesToHex(res.payment_preimage) || null;
|
||||
// Hard failures arrive as `{error, message}` and have already thrown in unwrap(). `payment_error`
|
||||
// is LND's soft channel — a routing failure, reported as a failed Payment rather than an exception.
|
||||
const failed = !!res.payment_error;
|
||||
|
||||
return {
|
||||
paymentHash: bytesToHex(res.payment_hash) || res.decoded?.payment_hash || '',
|
||||
preimage,
|
||||
amountMsat: decodedMsat ?? req.amountMsat ?? routedMsat,
|
||||
feeMsat,
|
||||
status: failed ? 'failed' : 'succeeded',
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
destination: res.decoded?.destination ?? null,
|
||||
memo: res.decoded?.description ?? null,
|
||||
failureReason: failed ? (res.payment_error ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── shared ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async fetchTxs(limit?: number): Promise<LndHubTx[]> {
|
||||
const res = await this.call<LndHubTx[]>({ path: '/gettxs', query: { limit } });
|
||||
return Array.isArray(res) ? res : [];
|
||||
}
|
||||
|
||||
private toInvoice(inv: LndHubUserInvoice): Invoice {
|
||||
const createdAt = num(inv.timestamp);
|
||||
const expiresAt = inv.expire_time ? createdAt + inv.expire_time : null;
|
||||
const paid = inv.ispaid === true;
|
||||
// LNDHub has no cancel and no hold invoices, so 'canceled' and 'accepted' are unreachable; an
|
||||
// unpaid invoice past its expiry is reported as expired rather than left open.
|
||||
const state: InvoiceState = paid
|
||||
? 'settled'
|
||||
: expiresAt != null && expiresAt < Math.floor(Date.now() / 1000)
|
||||
? 'expired'
|
||||
: 'open';
|
||||
|
||||
const amountMsat = inv.amt != null ? satsToMsat(num(inv.amt)) : null;
|
||||
const paidMsat =
|
||||
inv.amt_paid_msat != null
|
||||
? String(num(inv.amt_paid_msat))
|
||||
: inv.amt_paid_sat != null
|
||||
? satsToMsat(num(inv.amt_paid_sat))
|
||||
: paid
|
||||
? amountMsat
|
||||
: null;
|
||||
|
||||
return {
|
||||
paymentHash: bytesToHex(inv.r_hash ?? inv.payment_hash),
|
||||
bolt11: inv.payment_request ?? inv.pay_req ?? '',
|
||||
amountMsat,
|
||||
amountPaidMsat: paidMsat,
|
||||
memo: inv.description ?? inv.memo ?? null,
|
||||
state,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
// The settle time is not recorded upstream; only the ispaid flag is.
|
||||
settledAt: inv.settled_at ?? null,
|
||||
// Preimages of received payments stay with the custodian.
|
||||
preimage: null,
|
||||
isKeysend: false,
|
||||
isAmp: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// Nostr Wallet Connect (NIP-47) — port of Zeus's backends/NostrWalletConnect.ts.
|
||||
//
|
||||
// A NWC connection is a capability grant, not a node: a `nostr+walletconnect://` URI carries a wallet
|
||||
// service pubkey, one or more relays and a shared secret. Every operation is an encrypted nostr event
|
||||
// round-tripped through a relay, so the whole surface is lightning-only and the wallet decides which
|
||||
// commands it will honour.
|
||||
//
|
||||
// CAPABILITIES ARE DYNAMIC. This is the one backend whose `capabilities` set is not a constant: the
|
||||
// wallet advertises its methods in the `get_info` response (falling back to the kind-13194 info event),
|
||||
// and the set is rebuilt from that list on connect. Zeus hardcodes `supportsKeysend() = false` even
|
||||
// though plenty of NWC wallets do keysend; here `keysend` appears iff the wallet lists `pay_keysend`.
|
||||
// Before the first connect the set holds the NIP-47 baseline (make_invoice + pay_invoice), because
|
||||
// `supports()` is synchronous and cannot await the handshake.
|
||||
//
|
||||
// Differences from Zeus, all deliberate:
|
||||
//
|
||||
// 1. TRANSPORT. Zeus drives `NostrWebLNProvider` from `@getalby/sdk`, a WebLN shim that silently
|
||||
// converts sats↔msats. This uses `NWCClient` from the same package (v8), which speaks raw NIP-47
|
||||
// — every amount in and out is millisatoshis, matching this contract's msat-as-string rule with
|
||||
// no lossy hop through sats. Bun/Node 22 both provide the global WebSocket that nostr-tools needs.
|
||||
// 2. lookupInvoice. Zeus passes `Base64Utils.hexToBase64(r_hash)` (NostrWalletConnect.ts:78) — NIP-47
|
||||
// specifies payment_hash as **hex**, so that call cannot match. Hex is sent here.
|
||||
// 3. INVOICE/PAYMENT SPLIT. Zeus calls `list_transactions` and filters `type` client-side, twice.
|
||||
// NIP-47 takes `type` as a request parameter; the filter is pushed to the wallet, with the
|
||||
// client-side filter kept as a guard for wallets that ignore it.
|
||||
// 4. payInvoice. NIP-47 answers a payment with `{preimage, fees_paid}` and nothing else, so Zeus
|
||||
// returns a Payment with no hash. The payment hash is sha256(preimage) by definition, so it is
|
||||
// computed locally; the amount is filled from a best-effort `lookup_invoice` when the wallet
|
||||
// supports it, otherwise from the request.
|
||||
// 5. decodeInvoice is LOCAL. NIP-47 has no decode command, so this is the one operation that never
|
||||
// touches the relay: `../bolt11` (ported from Zeus's Bolt11Utils) decodes in-process. On-chain,
|
||||
// channels, peers and message signing do stay at BaseBackend's 501 — NIP-47 `sign_message` signs
|
||||
// with the node key, which is not what the contract's signMessage means.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Nip47Error, Nip47TimeoutError, NWCClient, type Nip47Method, type Nip47Transaction } from '@getalby/sdk/nwc';
|
||||
import { decodeBolt11 } from '../bolt11';
|
||||
import {
|
||||
BackendError,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type CreateInvoiceRequest,
|
||||
type DecodedInvoice,
|
||||
type Invoice,
|
||||
type InvoiceState,
|
||||
type KeysendRequest,
|
||||
type NodeInfo,
|
||||
type PayInvoiceRequest,
|
||||
type Payment,
|
||||
type PaymentStatus,
|
||||
} from '../types';
|
||||
import { BaseBackend } from './base';
|
||||
|
||||
export type NwcConfig = {
|
||||
/** A `nostr+walletconnect://<wallet-pubkey>?relay=…&secret=…` URI. */
|
||||
connectionUri: string;
|
||||
};
|
||||
|
||||
// ── upstream wire shapes ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The SDK types `state` as required, but it was added late to NIP-47 and pre-1.0 wallets omit it —
|
||||
* their only settlement signal is a non-zero `settled_at`. Amounts are millisatoshis throughout.
|
||||
*/
|
||||
type NwcTransaction = Omit<Nip47Transaction, 'state'> & { state?: Nip47Transaction['state'] };
|
||||
|
||||
/** NIP-47 error codes, mapped onto HTTP so routes.ts can answer honestly. */
|
||||
const ERROR_STATUS: Record<string, number> = {
|
||||
NOT_IMPLEMENTED: 501,
|
||||
UNSUPPORTED_ENCRYPTION: 501,
|
||||
UNAUTHORIZED: 403,
|
||||
RESTRICTED: 403,
|
||||
INSUFFICIENT_BALANCE: 402,
|
||||
QUOTA_EXCEEDED: 402,
|
||||
PAYMENT_FAILED: 502,
|
||||
NOT_FOUND: 404,
|
||||
RATE_LIMITED: 429,
|
||||
INTERNAL: 502,
|
||||
OTHER: 502,
|
||||
};
|
||||
|
||||
/** The keysend TLV that carries a human-readable message (the de-facto standard record). */
|
||||
const KEYSEND_MESSAGE_TLV = 34349334;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const now = (): number => Math.floor(Date.now() / 1000);
|
||||
|
||||
/** payment_hash = sha256(preimage) — how NIP-47 lets us recover a hash it never sends back. */
|
||||
function hashFromPreimage(preimage: string | undefined): string {
|
||||
if (!preimage) return '';
|
||||
return createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex');
|
||||
}
|
||||
|
||||
/** msat decimal string → the integer msats NIP-47 wants, with the JS-safe range enforced. */
|
||||
function toMsatNumber(msat: string, field: string): number {
|
||||
const value = BigInt(msat);
|
||||
if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
throw new BackendError(`${field}=${msat}msat is out of range for NIP-47`, 400, 'BAD_AMOUNT');
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function toBackendError(err: unknown, op: string): BackendError {
|
||||
if (err instanceof BackendError) return err;
|
||||
if (err instanceof Nip47TimeoutError) {
|
||||
return new BackendError(`nwc ${op} timed out: ${err.message}`, 504, err.code);
|
||||
}
|
||||
if (err instanceof Nip47Error) {
|
||||
return new BackendError(`nwc ${op} failed: ${err.message}`, ERROR_STATUS[err.code] ?? 502, err.code);
|
||||
}
|
||||
return new BackendError(`nwc ${op} failed: ${String(err)}`, 502, 'NWC_ERROR');
|
||||
}
|
||||
|
||||
function toInvoiceState(tx: NwcTransaction): InvoiceState {
|
||||
if (tx.state === 'settled') return 'settled';
|
||||
if (tx.state === 'accepted') return 'accepted';
|
||||
// NIP-47 has no explicit cancel; a hold invoice that was released comes back as failed.
|
||||
if (tx.state === 'failed') return 'canceled';
|
||||
if (tx.settled_at) return 'settled';
|
||||
if (tx.expires_at && tx.expires_at < now()) return 'expired';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function toPaymentStatus(tx: NwcTransaction): PaymentStatus {
|
||||
if (tx.state === 'settled') return 'succeeded';
|
||||
if (tx.state === 'failed') return 'failed';
|
||||
if (tx.state) return 'pending';
|
||||
return tx.settled_at ? 'succeeded' : 'pending';
|
||||
}
|
||||
|
||||
function toInvoice(tx: NwcTransaction): Invoice {
|
||||
const amountMsat = tx.amount != null ? String(tx.amount) : null;
|
||||
const state = toInvoiceState(tx);
|
||||
return {
|
||||
paymentHash: tx.payment_hash ?? '',
|
||||
bolt11: tx.invoice ?? '',
|
||||
amountMsat,
|
||||
amountPaidMsat: state === 'settled' ? amountMsat : null,
|
||||
memo: tx.description || null,
|
||||
state,
|
||||
createdAt: tx.created_at ?? now(),
|
||||
expiresAt: tx.expires_at || null,
|
||||
settledAt: tx.settled_at || null,
|
||||
preimage: tx.preimage || null,
|
||||
// An incoming payment with no BOLT11 attached to it can only have been a keysend.
|
||||
isKeysend: !tx.invoice && !!tx.payment_hash,
|
||||
// NIP-47 has no AMP concept.
|
||||
isAmp: false,
|
||||
};
|
||||
}
|
||||
|
||||
function toPayment(tx: NwcTransaction): Payment {
|
||||
return {
|
||||
paymentHash: tx.payment_hash ?? '',
|
||||
preimage: tx.preimage || null,
|
||||
amountMsat: String(tx.amount ?? 0),
|
||||
feeMsat: String(tx.fees_paid ?? 0),
|
||||
status: toPaymentStatus(tx),
|
||||
createdAt: tx.created_at ?? now(),
|
||||
// The wallet never names the payee; only the invoice it paid.
|
||||
destination: null,
|
||||
memo: tx.description || null,
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── backend ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class NwcBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'nwc';
|
||||
|
||||
/**
|
||||
* Mutable behind a ReadonlySet view — `capabilities` and `caps` are the same object, so rebuilding
|
||||
* the set after the get_info handshake is visible through `supports()` without reassigning a
|
||||
* readonly field. Seeded with the NIP-47 baseline until the wallet says otherwise.
|
||||
*/
|
||||
private readonly caps = new Set<Capability>(['lightningReceive', 'lightningSend']);
|
||||
protected readonly capabilities: ReadonlySet<Capability> = this.caps;
|
||||
|
||||
private client: NWCClient | null = null;
|
||||
private connecting: Promise<NWCClient> | null = null;
|
||||
private methods: ReadonlySet<Nip47Method> = new Set<Nip47Method>();
|
||||
|
||||
constructor(private readonly config: NwcConfig) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ── connection ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Single-flight connect: the relay subscription and the get_info handshake happen exactly once. */
|
||||
private async connect(): Promise<NWCClient> {
|
||||
if (this.client) return this.client;
|
||||
this.connecting ??= this.open().finally(() => {
|
||||
this.connecting = null;
|
||||
});
|
||||
return this.connecting;
|
||||
}
|
||||
|
||||
private async open(): Promise<NWCClient> {
|
||||
let client: NWCClient;
|
||||
try {
|
||||
client = new NWCClient({ nostrWalletConnectUrl: this.config.connectionUri });
|
||||
} catch (err) {
|
||||
throw new BackendError(`invalid NWC connection URI: ${String(err)}`, 400, 'BAD_CONFIG');
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await client.getInfo();
|
||||
this.applyMethods(info.methods);
|
||||
} catch (err) {
|
||||
// Wallets that do not implement get_info still publish a kind-13194 info event listing their
|
||||
// capabilities. If neither is reachable the connection itself is broken — surface that.
|
||||
try {
|
||||
const service = await client.getWalletServiceInfo();
|
||||
this.applyMethods(service.capabilities.filter((cap): cap is Nip47Method => cap !== 'notifications'));
|
||||
} catch {
|
||||
client.close();
|
||||
throw toBackendError(err, 'connect');
|
||||
}
|
||||
}
|
||||
|
||||
this.client = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Capability negotiation: the whole point of NWC's get_info. */
|
||||
private applyMethods(methods: Nip47Method[] | undefined): void {
|
||||
if (!methods?.length) return;
|
||||
this.methods = new Set(methods);
|
||||
this.caps.clear();
|
||||
if (this.methods.has('make_invoice')) this.caps.add('lightningReceive');
|
||||
if (this.methods.has('pay_invoice')) this.caps.add('lightningSend');
|
||||
if (this.methods.has('pay_keysend')) this.caps.add('keysend');
|
||||
}
|
||||
|
||||
private async run<T>(op: string, fn: (client: NWCClient) => Promise<T>): Promise<T> {
|
||||
const client = await this.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} catch (err) {
|
||||
throw toBackendError(err, op);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the relay subscription. Not part of WalletBackend; the factory calls it on teardown. */
|
||||
close(): void {
|
||||
this.client?.close();
|
||||
this.client = null;
|
||||
this.methods = new Set<Nip47Method>();
|
||||
}
|
||||
|
||||
// ── node / balances ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInfo(): Promise<NodeInfo> {
|
||||
const info = await this.run('get_info', (client) => client.getInfo());
|
||||
const network = info.network === 'mainnet' || !info.network ? 'bitcoin' : info.network;
|
||||
return {
|
||||
kind: this.kind,
|
||||
// The pubkey belongs to the wallet service's node, not to this connection.
|
||||
pubkey: info.pubkey || null,
|
||||
alias: info.alias || null,
|
||||
// NIP-47 carries no implementation version.
|
||||
version: null,
|
||||
network: (['bitcoin', 'testnet', 'signet', 'regtest'] as const).includes(network as BitcoinNetwork)
|
||||
? (network as BitcoinNetwork)
|
||||
: 'bitcoin',
|
||||
blockHeight: info.block_height ?? null,
|
||||
// A wallet that answers at all is by definition usable; there is no sync flag in NIP-47.
|
||||
synced: true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const res = await this.run('get_balance', (client) => client.getBalance());
|
||||
return {
|
||||
// NWC is lightning-only; there is no on-chain side to report.
|
||||
onchainConfirmed: 0,
|
||||
onchainUnconfirmed: 0,
|
||||
// `balance` is msats (the WebLN shim Zeus uses divides by 1000 before the UI ever sees it).
|
||||
lightningBalance: Math.floor((res.balance ?? 0) / 1000),
|
||||
lightningInbound: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── lightning ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
override async getInvoices(opts?: { limit?: number }): Promise<Invoice[]> {
|
||||
const res = await this.run('list_transactions', (client) =>
|
||||
client.listTransactions({ type: 'incoming', limit: opts?.limit, unpaid: true }),
|
||||
);
|
||||
// The `type` filter is a request parameter, but older wallets ignore it — filter again.
|
||||
return (res.transactions ?? []).filter((tx) => tx.type !== 'outgoing').map(toInvoice);
|
||||
}
|
||||
|
||||
override async createInvoice(req: CreateInvoiceRequest): Promise<Invoice> {
|
||||
if (!this.caps.has('lightningReceive')) return this.notSupported('lightning receive');
|
||||
// No NIP-47 wallet accepts a caller-supplied preimage or an AMP invoice, and `private` has no
|
||||
// equivalent — the wallet picks its own route hints.
|
||||
if (req.preimage) return this.notSupported('custom preimages');
|
||||
if (req.isAmp) return this.notSupported('AMP invoices');
|
||||
if (!req.amountMsat || req.amountMsat === '0') {
|
||||
throw new BackendError('nwc requires an invoice amount', 400, 'AMOUNT_REQUIRED');
|
||||
}
|
||||
const amount = toMsatNumber(req.amountMsat, 'amountMsat');
|
||||
|
||||
const tx = await this.run('make_invoice', (client) =>
|
||||
client.makeInvoice({ amount, description: req.memo, expiry: req.expirySeconds }),
|
||||
);
|
||||
return toInvoice(tx);
|
||||
}
|
||||
|
||||
override async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
if (!this.methods.size || this.methods.has('lookup_invoice')) {
|
||||
const tx = await this.lookup({ payment_hash: paymentHash });
|
||||
return tx ? toInvoice(tx) : null;
|
||||
}
|
||||
return this.notSupported('invoice lookup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Purely local — no relay round-trip, and it works while the wallet is unreachable. The invoice is
|
||||
* self-describing, so there is nothing the wallet service could add beyond what the bytes already say.
|
||||
*/
|
||||
override async decodeInvoice(bolt11: string): Promise<DecodedInvoice> {
|
||||
return decodeBolt11(bolt11);
|
||||
}
|
||||
|
||||
override async getPayments(opts?: { limit?: number }): Promise<Payment[]> {
|
||||
const res = await this.run('list_transactions', (client) =>
|
||||
client.listTransactions({ type: 'outgoing', limit: opts?.limit, unpaid_outgoing: true }),
|
||||
);
|
||||
return (res.transactions ?? []).filter((tx) => tx.type !== 'incoming').map(toPayment);
|
||||
}
|
||||
|
||||
override async payInvoice(req: PayInvoiceRequest): Promise<Payment> {
|
||||
if (!this.caps.has('lightningSend')) return this.notSupported('lightning send');
|
||||
// Fee limits and timeouts are the wallet's own budget policy; NIP-47 has no field for either.
|
||||
const amount = req.amountMsat ? toMsatNumber(req.amountMsat, 'amountMsat') : undefined;
|
||||
const res = await this.run('pay_invoice', (client) => client.payInvoice({ invoice: req.bolt11, amount }));
|
||||
|
||||
const paymentHash = hashFromPreimage(res.preimage);
|
||||
// pay_invoice returns only {preimage, fees_paid}. Recover the rest from the wallet's own record
|
||||
// when it keeps one — best effort, never fatal, since the payment has already settled.
|
||||
const record = paymentHash ? await this.lookupQuietly({ payment_hash: paymentHash }) : null;
|
||||
|
||||
return {
|
||||
paymentHash,
|
||||
preimage: res.preimage || null,
|
||||
amountMsat: record?.amount != null ? String(record.amount) : (req.amountMsat ?? '0'),
|
||||
feeMsat: String(res.fees_paid ?? record?.fees_paid ?? 0),
|
||||
// A NIP-47 pay_invoice that resolves has succeeded; a failure arrives as a Nip47WalletError.
|
||||
status: 'succeeded',
|
||||
createdAt: record?.created_at ?? now(),
|
||||
destination: null,
|
||||
memo: record?.description || null,
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
override async sendKeysend(req: KeysendRequest): Promise<Payment> {
|
||||
if (!this.caps.has('keysend')) return this.notSupported('keysend');
|
||||
const amount = toMsatNumber(req.amountMsat, 'amountMsat');
|
||||
// NIP-47 carries TLV values as hex.
|
||||
const tlvRecords = req.message
|
||||
? [{ type: KEYSEND_MESSAGE_TLV, value: Buffer.from(req.message, 'utf8').toString('hex') }]
|
||||
: undefined;
|
||||
|
||||
const res = await this.run('pay_keysend', (client) =>
|
||||
client.payKeysend({ pubkey: req.destination, amount, tlv_records: tlvRecords }),
|
||||
);
|
||||
return {
|
||||
paymentHash: hashFromPreimage(res.preimage),
|
||||
preimage: res.preimage || null,
|
||||
amountMsat: req.amountMsat,
|
||||
feeMsat: String(res.fees_paid ?? 0),
|
||||
status: 'succeeded',
|
||||
createdAt: now(),
|
||||
destination: req.destination,
|
||||
memo: req.message ?? null,
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── shared ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async lookup(request: { payment_hash?: string; invoice?: string }): Promise<NwcTransaction | null> {
|
||||
try {
|
||||
return await this.run('lookup_invoice', (client) => client.lookupInvoice(request));
|
||||
} catch (err) {
|
||||
// A wallet that has never seen the hash answers NOT_FOUND; that is an absence, not a failure.
|
||||
if (err instanceof BackendError && err.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async lookupQuietly(request: { payment_hash?: string; invoice?: string }): Promise<NwcTransaction | null> {
|
||||
if (this.methods.size && !this.methods.has('lookup_invoice')) return null;
|
||||
try {
|
||||
return await this.lookup(request);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
// The native on-chain wallet backend.
|
||||
//
|
||||
// This is the server-side replacement for Zeus's two embedded backends. EmbeddedLND
|
||||
// (backends/EmbeddedLND.ts) and LdkNode (backends/LdkNode.ts) are thin JS shims over React Native
|
||||
// native modules — `lndmobile`, `ldk-node-rn` — that bundle an actual node into the app process.
|
||||
// Neither exists off a phone, so neither can be ported into a Bun sidecar. Instead this backend is a
|
||||
// real wallet in its own right: it derives addresses from a BIP32 account xpub, reads the chain from an
|
||||
// Esplora HTTP API (chain.ts), and builds/signs its own transactions with bitcoinjs-lib (psbt.ts).
|
||||
//
|
||||
// WATCH-ONLY WHILE LOCKED is the load-bearing design property. Everything a UI polls —
|
||||
// getInfo / getBalances / getTransactions / getNewAddress / getUtxos / estimateFees — is derived from
|
||||
// the account XPUB and public chain data, so it works with the wallet locked and no key material in
|
||||
// memory. `signer.withRoot` is reached from exactly two methods, sendCoins and signMessage, and both
|
||||
// fail fast with WalletLockedError before doing any work. Nothing else in this file touches the signer.
|
||||
//
|
||||
// There is no lightning here at all: the capability set omits every lightning flag, so invoices,
|
||||
// payments, channels and peers all fall through to BaseBackend's 501.
|
||||
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371';
|
||||
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||
import type { EsploraAddress, EsploraChain, EsploraTx } from '../chain';
|
||||
import {
|
||||
buildPsbt,
|
||||
coinTypeFor,
|
||||
initEcc,
|
||||
networkFor,
|
||||
outputScriptFor,
|
||||
scriptType,
|
||||
selectCoins,
|
||||
signAndFinalize,
|
||||
type PsbtInputSource,
|
||||
type PsbtOutputSpec,
|
||||
type SpendableUtxo,
|
||||
} from '../psbt';
|
||||
import {
|
||||
BackendError,
|
||||
WalletLockedError,
|
||||
type AddressType,
|
||||
type Balances,
|
||||
type BackendKind,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type FeeEstimates,
|
||||
type NewAddressRequest,
|
||||
type NodeInfo,
|
||||
type OnchainTx,
|
||||
type SendCoinsRequest,
|
||||
type SendCoinsResult,
|
||||
type SignMessageResult,
|
||||
type Utxo,
|
||||
type VerifyMessageResult,
|
||||
} from '../types';
|
||||
import { BaseBackend } from './base';
|
||||
|
||||
// ── the signer boundary ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The wallet's key custodian, implemented by keys.ts and injected here.
|
||||
*
|
||||
* This backend never sees a seed, a mnemonic, a passphrase or a file path. It receives a root HDKey for
|
||||
* the duration of one synchronous callback and nothing more, which is what keeps the watch-only path
|
||||
* genuinely key-free rather than key-free by convention.
|
||||
*
|
||||
* `withRoot` MUST throw `WalletLockedError` when the wallet is locked, and MUST NOT be async — an async
|
||||
* borrow would pin the root in memory across arbitrary awaits.
|
||||
*/
|
||||
export interface WalletSigner {
|
||||
isUnlocked(): boolean;
|
||||
withRoot<T>(fn: (root: HDKey) => T): T;
|
||||
}
|
||||
|
||||
// ── extended key parsing ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* SLIP-132 version bytes. @scure/bip32 refuses an extended key whose version does not match the
|
||||
* `versions` it was handed, so the prefix has to be recognised before the key can be imported. Wallets
|
||||
* export the purpose-tagged forms (zpub for BIP84, ypub for BIP49, vpub/upub on testnet) as often as
|
||||
* they export a plain xpub, and all of them are the same key with different four leading bytes.
|
||||
*/
|
||||
const XPUB_VERSIONS: Record<string, { public: number; private: number }> = {
|
||||
xpub: { public: 0x0488b21e, private: 0x0488ade4 },
|
||||
ypub: { public: 0x049d7cb2, private: 0x049d7878 },
|
||||
zpub: { public: 0x04b24746, private: 0x04b2430c },
|
||||
tpub: { public: 0x043587cf, private: 0x04358394 },
|
||||
upub: { public: 0x044a5262, private: 0x044a4e28 },
|
||||
vpub: { public: 0x045f1cf6, private: 0x045f18bc },
|
||||
};
|
||||
|
||||
function parseAccountXpub(key: string): HDKey {
|
||||
const prefix = key.slice(0, 4).toLowerCase();
|
||||
const versions = XPUB_VERSIONS[prefix];
|
||||
if (!versions) throw new BackendError(`unrecognised extended key prefix '${prefix}'`, 400, 'BAD_XPUB');
|
||||
try {
|
||||
return HDKey.fromExtendedKey(key, versions);
|
||||
} catch (err) {
|
||||
throw new BackendError(`could not parse the account extended key: ${String(err)}`, 400, 'BAD_XPUB');
|
||||
}
|
||||
}
|
||||
|
||||
// ── derivation ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** BIP44 purpose per script type: 44' legacy, 49' wrapped segwit, 84' native segwit, 86' taproot. */
|
||||
const PURPOSE: Record<AddressType, number> = {
|
||||
p2pkh: 44,
|
||||
'p2sh-p2wpkh': 49,
|
||||
p2wpkh: 84,
|
||||
p2tr: 86,
|
||||
};
|
||||
|
||||
/** Preference order when the caller does not name a script type. Native segwit first. */
|
||||
const TYPE_PREFERENCE: readonly AddressType[] = ['p2wpkh', 'p2tr', 'p2sh-p2wpkh', 'p2pkh'];
|
||||
|
||||
/** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */
|
||||
type ChainIndex = 0 | 1;
|
||||
|
||||
type Account = {
|
||||
type: AddressType;
|
||||
node: HDKey;
|
||||
/** Account-level path from the wallet root, e.g. `m/84'/0'/0'`. */
|
||||
basePath: string;
|
||||
};
|
||||
|
||||
type AddressEntry = {
|
||||
type: AddressType;
|
||||
chain: ChainIndex;
|
||||
index: number;
|
||||
address: string;
|
||||
/** Compressed 33-byte pubkey, hex. */
|
||||
pubkeyHex: string;
|
||||
scriptPubKeyHex: string;
|
||||
/** Full path from the wallet root — what the signer derives with. */
|
||||
path: string;
|
||||
/** Path relative to the account xpub, which is what the public Utxo type carries. */
|
||||
relPath: string;
|
||||
};
|
||||
|
||||
type ScannedAddress = AddressEntry & {
|
||||
confirmedSats: number;
|
||||
unconfirmedSats: number;
|
||||
txCount: number;
|
||||
used: boolean;
|
||||
};
|
||||
|
||||
type WalletScan = {
|
||||
at: number;
|
||||
tipHeight: number;
|
||||
addresses: ScannedAddress[];
|
||||
/** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. */
|
||||
byScript: Map<string, ScannedAddress>;
|
||||
};
|
||||
|
||||
// ── tuning ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** BIP44's standard gap limit: 20 consecutive unused addresses ends the scan for a chain. */
|
||||
const GAP_LIMIT = 20;
|
||||
|
||||
/** How long a discovery scan stays fresh. Short enough to feel live, long enough that a dashboard
|
||||
* polling getInfo/getBalances/getTransactions together costs one scan rather than three. */
|
||||
const SCAN_TTL_MS = 30_000;
|
||||
|
||||
/** Parallel Esplora requests. Public instances rate-limit, so this stays modest. */
|
||||
const REQUEST_CONCURRENCY = 6;
|
||||
|
||||
/** Hard stop on a runaway scan — a misconfigured xpub against a busy chain must not loop forever. */
|
||||
const MAX_SCAN_INDEX = 1_000;
|
||||
|
||||
const DEFAULT_TX_LIMIT = 100;
|
||||
|
||||
// ── the backend ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OnchainBackendOptions = {
|
||||
chain: EsploraChain;
|
||||
network: BitcoinNetwork;
|
||||
/**
|
||||
* The account-level extended public key. A bare string is taken as the BIP84 (p2wpkh) account; pass a
|
||||
* map to enable more than one script type, e.g. `{ p2wpkh: 'zpub…', p2tr: 'xpub…' }`.
|
||||
*/
|
||||
accountXpub: string | Partial<Record<AddressType, string>>;
|
||||
signer: WalletSigner;
|
||||
};
|
||||
|
||||
export class OnchainBackend extends BaseBackend {
|
||||
readonly kind: BackendKind = 'onchain';
|
||||
|
||||
protected readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
|
||||
'onchainReceive',
|
||||
'onchainSend',
|
||||
'coinControl',
|
||||
'psbt',
|
||||
'sweep',
|
||||
'signMessage',
|
||||
]);
|
||||
|
||||
private readonly chain: EsploraChain;
|
||||
private readonly network: BitcoinNetwork;
|
||||
private readonly btcNetwork: bitcoin.Network;
|
||||
private readonly signer: WalletSigner;
|
||||
private readonly accounts: Map<AddressType, Account>;
|
||||
private readonly defaultType: AddressType;
|
||||
|
||||
/** Derivation is pure EC math over a fixed xpub, so every address is derived at most once. */
|
||||
private readonly derived = new Map<string, AddressEntry>();
|
||||
|
||||
/** In-memory issuance high-water mark per `type:chain`, so consecutive getNewAddress calls advance.
|
||||
* Deliberately not persisted: after a restart the first unused address is recomputed from the chain,
|
||||
* which is correct as soon as an issued address has actually been paid. */
|
||||
private readonly issued = new Map<string, number>();
|
||||
|
||||
private scanCache: WalletScan | null = null;
|
||||
private scanInflight: Promise<WalletScan> | null = null;
|
||||
|
||||
constructor(opts: OnchainBackendOptions) {
|
||||
super();
|
||||
initEcc();
|
||||
this.chain = opts.chain;
|
||||
this.network = opts.network;
|
||||
this.btcNetwork = networkFor(opts.network);
|
||||
this.signer = opts.signer;
|
||||
|
||||
const coin = coinTypeFor(opts.network);
|
||||
const raw = typeof opts.accountXpub === 'string' ? { p2wpkh: opts.accountXpub } : opts.accountXpub;
|
||||
|
||||
this.accounts = new Map();
|
||||
for (const type of TYPE_PREFERENCE) {
|
||||
const xpub = raw[type];
|
||||
if (!xpub) continue;
|
||||
this.accounts.set(type, {
|
||||
type,
|
||||
node: parseAccountXpub(xpub),
|
||||
basePath: `m/${PURPOSE[type]}'/${coin}'/0'`,
|
||||
});
|
||||
}
|
||||
|
||||
const first = TYPE_PREFERENCE.find((t) => this.accounts.has(t));
|
||||
if (!first) throw new BackendError('the on-chain backend needs at least one account xpub', 400, 'BAD_XPUB');
|
||||
this.defaultType = first;
|
||||
}
|
||||
|
||||
// ── derivation helpers ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private accountFor(type: AddressType): Account {
|
||||
const account = this.accounts.get(type);
|
||||
if (!account) {
|
||||
const have = [...this.accounts.keys()].join(', ') || 'none';
|
||||
throw new BackendError(`no ${type} account is configured (have: ${have})`, 400, 'NO_ACCOUNT');
|
||||
}
|
||||
return account;
|
||||
}
|
||||
|
||||
private derive(type: AddressType, chain: ChainIndex, index: number): AddressEntry {
|
||||
const cacheKey = `${type}:${chain}:${index}`;
|
||||
const hit = this.derived.get(cacheKey);
|
||||
if (hit) return hit;
|
||||
|
||||
const account = this.accountFor(type);
|
||||
const node = account.node.derive(`m/${chain}/${index}`);
|
||||
const pub = node.publicKey;
|
||||
if (!pub) throw new BackendError(`failed to derive ${type} ${chain}/${index}`, 500);
|
||||
const pubkey = Buffer.from(pub);
|
||||
const network = this.btcNetwork;
|
||||
|
||||
const payment =
|
||||
type === 'p2wpkh'
|
||||
? bitcoin.payments.p2wpkh({ pubkey, network })
|
||||
: type === 'p2tr'
|
||||
? bitcoin.payments.p2tr({ internalPubkey: toXOnly(pubkey), network })
|
||||
: type === 'p2sh-p2wpkh'
|
||||
? bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey, network }), network })
|
||||
: bitcoin.payments.p2pkh({ pubkey, network });
|
||||
|
||||
if (!payment.address || !payment.output) throw new BackendError(`failed to build a ${type} address`, 500);
|
||||
|
||||
const entry: AddressEntry = {
|
||||
type,
|
||||
chain,
|
||||
index,
|
||||
address: payment.address,
|
||||
pubkeyHex: pubkey.toString('hex'),
|
||||
scriptPubKeyHex: payment.output.toString('hex'),
|
||||
path: `${account.basePath}/${chain}/${index}`,
|
||||
relPath: `${chain}/${index}`,
|
||||
};
|
||||
this.derived.set(cacheKey, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ── discovery scan ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gap-limit scan of every configured account across both chains, memoised for SCAN_TTL_MS. Concurrent
|
||||
* callers share one in-flight scan rather than each starting their own — without that, the three
|
||||
* queries a wallet screen fires on mount would triple the request count against Esplora.
|
||||
*/
|
||||
private async scan(): Promise<WalletScan> {
|
||||
const fresh = this.scanCache;
|
||||
if (fresh && Date.now() - fresh.at < SCAN_TTL_MS) return fresh;
|
||||
if (this.scanInflight) return this.scanInflight;
|
||||
|
||||
const run = this.runScan().then(
|
||||
(result) => {
|
||||
this.scanCache = result;
|
||||
this.scanInflight = null;
|
||||
return result;
|
||||
},
|
||||
(err: unknown) => {
|
||||
this.scanInflight = null;
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
this.scanInflight = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Drop the cache after a send, so the spent coins disappear from the next read immediately. */
|
||||
private invalidateScan(): void {
|
||||
this.scanCache = null;
|
||||
}
|
||||
|
||||
private async runScan(): Promise<WalletScan> {
|
||||
const tipHeight = await this.chain.getTipHeight();
|
||||
const addresses: ScannedAddress[] = [];
|
||||
|
||||
// A wallet holds an account xpub for all four BIP purposes, but a full gap-limit walk of every one
|
||||
// costs 4 types x 2 chains x 20 addresses = 160 requests, which trips the rate limit on every public
|
||||
// Esplora instance. Only the wallet's own script type is walked unconditionally; the others are
|
||||
// probed at receive index 0 first (one request each) and walked only if that address has ever been
|
||||
// used. A freshly generated seed therefore costs 43 requests instead of 160, while a seed recovered
|
||||
// from a wallet that used a different script type is still found rather than silently reported empty.
|
||||
for (const type of this.accounts.keys()) {
|
||||
if (type !== this.defaultType && !(await this.hasHistory(type))) continue;
|
||||
for (const chain of [0, 1] as ChainIndex[]) {
|
||||
addresses.push(...(await this.scanChain(type, chain)));
|
||||
}
|
||||
}
|
||||
|
||||
const byScript = new Map<string, ScannedAddress>();
|
||||
for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry);
|
||||
|
||||
return { at: Date.now(), tipHeight, addresses, byScript };
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this script type ever been used at all? One request against receive index 0, which is the
|
||||
* address any wallet hands out first — so a used account is essentially never missed, and an unused
|
||||
* one costs a single call instead of a forty-address walk.
|
||||
*/
|
||||
private async hasHistory(type: AddressType): Promise<boolean> {
|
||||
const stat = await this.chain.getAddress(this.derive(type, 0, 0).address);
|
||||
return toScannedAddress(this.derive(type, 0, 0), stat).used;
|
||||
}
|
||||
|
||||
/** Walk one (type, chain) pair a gap-limit window at a time until GAP_LIMIT consecutive misses. */
|
||||
private async scanChain(type: AddressType, chain: ChainIndex): Promise<ScannedAddress[]> {
|
||||
const found: ScannedAddress[] = [];
|
||||
let index = 0;
|
||||
let gap = 0;
|
||||
|
||||
while (gap < GAP_LIMIT && index < MAX_SCAN_INDEX) {
|
||||
const window = Array.from({ length: GAP_LIMIT }, (_, i) => this.derive(type, chain, index + i));
|
||||
const stats = await mapLimit(window, REQUEST_CONCURRENCY, (entry) => this.chain.getAddress(entry.address));
|
||||
|
||||
for (let i = 0; i < window.length; i++) {
|
||||
const entry = window[i];
|
||||
const stat = stats[i];
|
||||
if (!entry || !stat) continue;
|
||||
const scanned = toScannedAddress(entry, stat);
|
||||
found.push(scanned);
|
||||
gap = scanned.used ? 0 : gap + 1;
|
||||
if (gap >= GAP_LIMIT) break;
|
||||
}
|
||||
index += window.length;
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
// ── watch-only reads (no key material required) ────────────────────────────────────────────────
|
||||
|
||||
async getInfo(): Promise<NodeInfo> {
|
||||
// Deliberately does not force a scan: this is the cheapest liveness probe the wallet has.
|
||||
const blockHeight = await this.chain.getTipHeight();
|
||||
return {
|
||||
kind: 'onchain',
|
||||
// An Esplora-backed wallet has no node identity, no alias and no upstream version to report.
|
||||
pubkey: null,
|
||||
alias: null,
|
||||
version: null,
|
||||
network: this.network,
|
||||
blockHeight,
|
||||
synced: true,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
const scan = await this.scan();
|
||||
let confirmed = 0;
|
||||
let unconfirmed = 0;
|
||||
for (const entry of scan.addresses) {
|
||||
confirmed += entry.confirmedSats;
|
||||
unconfirmed += entry.unconfirmedSats;
|
||||
}
|
||||
return {
|
||||
onchainConfirmed: confirmed,
|
||||
onchainUnconfirmed: unconfirmed,
|
||||
// No channels exist, and null is the contract's "this backend has no lightning" value.
|
||||
lightningBalance: null,
|
||||
lightningInbound: null,
|
||||
};
|
||||
}
|
||||
|
||||
override async estimateFees(): Promise<FeeEstimates> {
|
||||
return this.chain.getFeeEstimates();
|
||||
}
|
||||
|
||||
override async getUtxos(): Promise<Utxo[]> {
|
||||
const scan = await this.scan();
|
||||
const utxos = await this.collectUtxos(scan);
|
||||
return utxos.map((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.amountSats,
|
||||
address: u.address,
|
||||
addressType: u.addressType,
|
||||
confirmations: u.confirmations,
|
||||
// The public contract wants the path relative to the account xpub, not the absolute one the
|
||||
// signer uses, so it is recomputed from the tail of the full path.
|
||||
derivationPath: u.derivationPath.split('/').slice(-2).join('/'),
|
||||
// Freezing is a policy layer above this backend; nothing on-chain marks a coin frozen.
|
||||
frozen: false,
|
||||
}));
|
||||
}
|
||||
|
||||
override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> {
|
||||
const type = req?.type ?? this.defaultType;
|
||||
this.accountFor(type);
|
||||
const entry = await this.nextUnused(type, 0, req?.peek === true);
|
||||
return { address: entry.address, type };
|
||||
}
|
||||
|
||||
override async getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]> {
|
||||
const scan = await this.scan();
|
||||
const limit = opts?.limit ?? DEFAULT_TX_LIMIT;
|
||||
|
||||
// Only addresses that have ever been touched can appear in history.
|
||||
const touched = scan.addresses.filter((a) => a.used);
|
||||
const pages = await mapLimit(touched, REQUEST_CONCURRENCY, (a) => this.chain.getAddressTxs(a.address));
|
||||
|
||||
const seen = new Map<string, EsploraTx>();
|
||||
for (const page of pages) {
|
||||
for (const tx of page) if (!seen.has(tx.txid)) seen.set(tx.txid, tx);
|
||||
}
|
||||
|
||||
const txs = [...seen.values()].map((tx) => this.toOnchainTx(tx, scan));
|
||||
txs.sort((a, b) => {
|
||||
// Unconfirmed first (they have no height), then newest block, then newest timestamp.
|
||||
const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER;
|
||||
if (ah !== bh) return bh - ah;
|
||||
return (b.timestamp ?? 0) - (a.timestamp ?? 0);
|
||||
});
|
||||
return txs.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Score one Esplora transaction against the wallet's own scripts. */
|
||||
private toOnchainTx(tx: EsploraTx, scan: WalletScan): OnchainTx {
|
||||
let credit = 0;
|
||||
let debit = 0;
|
||||
const ours: string[] = [];
|
||||
const theirs: string[] = [];
|
||||
|
||||
for (const out of tx.vout) {
|
||||
const mine = scan.byScript.get(out.scriptpubkey);
|
||||
if (mine) {
|
||||
credit += out.value;
|
||||
ours.push(mine.address);
|
||||
} else if (out.scriptpubkey_address) {
|
||||
theirs.push(out.scriptpubkey_address);
|
||||
}
|
||||
}
|
||||
for (const input of tx.vin) {
|
||||
const prevout = input.prevout;
|
||||
if (prevout && scan.byScript.has(prevout.scriptpubkey)) debit += prevout.value;
|
||||
}
|
||||
|
||||
const height = tx.status.confirmed ? (tx.status.block_height ?? null) : null;
|
||||
const confirmations = height === null ? 0 : Math.max(0, scan.tipHeight - height + 1);
|
||||
const amount = credit - debit;
|
||||
|
||||
return {
|
||||
txid: tx.txid,
|
||||
amount,
|
||||
// The fee is only ours to report when we funded an input; for an incoming payment the sender
|
||||
// paid it and attributing it to this wallet would be a lie.
|
||||
feeSats: debit > 0 ? tx.fee : null,
|
||||
blockHeight: height,
|
||||
timestamp: tx.status.block_time ?? null,
|
||||
confirmations,
|
||||
// No label store in this backend; SendCoinsRequest.label is accepted and dropped.
|
||||
label: null,
|
||||
destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours,
|
||||
// Fetching /tx/{txid}/hex per transaction would double the request count for a list view. The
|
||||
// contract permits null, and the raw hex is fetched on demand where it is actually needed.
|
||||
rawHex: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── spending (requires the root key) ───────────────────────────────────────────────────────────
|
||||
|
||||
override async sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult> {
|
||||
// Fail before any network work rather than after building a PSBT we cannot sign.
|
||||
if (!this.signer.isUnlocked()) throw new WalletLockedError();
|
||||
if (!Number.isFinite(req.satPerVbyte) || req.satPerVbyte <= 0) {
|
||||
throw new BackendError('satPerVbyte must be a positive number', 400, 'INVALID_FEE_RATE');
|
||||
}
|
||||
|
||||
const recipientScript = outputScriptFor(req.address, this.btcNetwork);
|
||||
const recipientType = scriptType(recipientScript);
|
||||
|
||||
const scan = await this.scan();
|
||||
const spendable = await this.collectUtxos(scan);
|
||||
|
||||
const selection = selectCoins({
|
||||
utxos: spendable,
|
||||
targetSats: req.amountSats ?? 0,
|
||||
sendAll: req.sendAll === true,
|
||||
satPerVbyte: req.satPerVbyte,
|
||||
recipientType,
|
||||
changeType: this.defaultType,
|
||||
outpoints: req.outpoints,
|
||||
spendUnconfirmed: req.spendUnconfirmed,
|
||||
});
|
||||
|
||||
// Legacy inputs commit to the whole previous transaction, so it has to be fetched; segwit inputs
|
||||
// carry their own value and script in the PSBT and need nothing extra.
|
||||
const inputs = await mapLimit<SpendableUtxo, PsbtInputSource>(selection.inputs, REQUEST_CONCURRENCY, async (u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.amountSats,
|
||||
addressType: u.addressType,
|
||||
scriptPubKeyHex: u.scriptPubKeyHex,
|
||||
pubkeyHex: u.pubkeyHex,
|
||||
derivationPath: u.derivationPath,
|
||||
prevTxHex: u.addressType === 'p2pkh' ? await this.chain.getTxHex(u.txid) : undefined,
|
||||
}));
|
||||
|
||||
const outputs: PsbtOutputSpec[] = [{ address: req.address, amountSats: selection.outputSats }];
|
||||
if (selection.changeSats !== null) {
|
||||
const change = await this.nextUnused(this.defaultType, 1, false);
|
||||
outputs.push({ address: change.address, amountSats: selection.changeSats });
|
||||
// Change-is-always-last is a well-known chain-analysis heuristic. One shuffle removes it.
|
||||
if (Math.random() < 0.5) outputs.reverse();
|
||||
}
|
||||
|
||||
const { psbt, inputPaths } = buildPsbt({
|
||||
network: this.btcNetwork,
|
||||
inputs,
|
||||
outputs,
|
||||
rbf: req.rbf,
|
||||
});
|
||||
|
||||
// The only place a root key enters this file. Synchronous, so the key is not held across an await.
|
||||
const signed = this.signer.withRoot((root) => signAndFinalize(psbt, root, inputPaths));
|
||||
|
||||
const txid = await this.chain.broadcast(signed.rawHex);
|
||||
this.invalidateScan();
|
||||
return { txid, feeSats: signed.feeSats, rawHex: signed.rawHex };
|
||||
}
|
||||
|
||||
override async signMessage(message: string): Promise<SignMessageResult> {
|
||||
if (!this.signer.isUnlocked()) throw new WalletLockedError();
|
||||
// BIP137-style: the identity key is the first receive key of the default account, which is what
|
||||
// every other wallet that signs with a derived key uses.
|
||||
const entry = this.derive(this.defaultType, 0, 0);
|
||||
const hash = bitcoinMessageHash(message, this.btcNetwork);
|
||||
|
||||
const signature = this.signer.withRoot((root) => {
|
||||
const node = root.derive(entry.path.replace(/[hH]/g, "'"));
|
||||
const priv = node.privateKey;
|
||||
if (!priv) throw new BackendError('derived node has no private key', 500);
|
||||
const { signature: sig, recoveryId } = ecc.signRecoverable(hash, priv);
|
||||
// Header byte: 27 + recovery id, +4 because the key is compressed.
|
||||
const header = Buffer.from([27 + recoveryId + 4]);
|
||||
return Buffer.concat([header, Buffer.from(sig)]).toString('base64');
|
||||
});
|
||||
|
||||
return { signature };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification needs no key material — only the xpub — so it stays available while locked.
|
||||
*
|
||||
* The contract takes no claimed address, and a recoverable signature *always* recovers some pubkey,
|
||||
* so "did this recover" is not a meaningful answer: a tampered message would still report valid.
|
||||
* `valid` therefore means "this wallet signed this message" — the recovered key is compared against
|
||||
* the identity key `signMessage` uses. The recovered pubkey is returned either way, so a caller
|
||||
* verifying a third party's signature can do its own comparison.
|
||||
*/
|
||||
override async verifyMessage(message: string, signature: string): Promise<VerifyMessageResult> {
|
||||
const sig = Buffer.from(signature, 'base64');
|
||||
if (sig.length !== 65) return { valid: false, pubkey: null };
|
||||
|
||||
const header = sig[0];
|
||||
if (header === undefined || header < 27 || header > 42) return { valid: false, pubkey: null };
|
||||
const recoveryId = ((header - 27) & 3) as 0 | 1 | 2 | 3;
|
||||
const compressed = ((header - 27) & 4) !== 0;
|
||||
const compact = sig.subarray(1);
|
||||
const hash = bitcoinMessageHash(message, this.btcNetwork);
|
||||
|
||||
try {
|
||||
const recovered = ecc.recover(hash, compact, recoveryId, compressed);
|
||||
if (!recovered || !ecc.verify(hash, recovered, compact)) return { valid: false, pubkey: null };
|
||||
const pubkey = Buffer.from(recovered).toString('hex');
|
||||
return { valid: pubkey === this.derive(this.defaultType, 0, 0).pubkeyHex, pubkey };
|
||||
} catch {
|
||||
return { valid: false, pubkey: null };
|
||||
}
|
||||
}
|
||||
|
||||
// ── shared internals ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch UTXOs for every scanned address that still holds a balance. */
|
||||
private async collectUtxos(scan: WalletScan): Promise<SpendableUtxo[]> {
|
||||
// An address whose funded and spent counts match holds nothing; asking Esplora about it is a
|
||||
// wasted round trip, and on a wallet with long history that is most of the address set.
|
||||
const funded = scan.addresses.filter((a) => a.confirmedSats + a.unconfirmedSats > 0);
|
||||
const sets = await mapLimit(funded, REQUEST_CONCURRENCY, async (entry) => {
|
||||
const utxos = await this.chain.getAddressUtxos(entry.address);
|
||||
return utxos.map<SpendableUtxo>((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
amountSats: u.value,
|
||||
address: entry.address,
|
||||
addressType: entry.type,
|
||||
confirmations:
|
||||
u.status.confirmed && u.status.block_height !== undefined
|
||||
? Math.max(0, scan.tipHeight - u.status.block_height + 1)
|
||||
: 0,
|
||||
derivationPath: entry.path,
|
||||
frozen: false,
|
||||
scriptPubKeyHex: entry.scriptPubKeyHex,
|
||||
pubkeyHex: entry.pubkeyHex,
|
||||
}));
|
||||
});
|
||||
return sets.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* First address on a chain that the blockchain has never seen, at or beyond the in-memory issuance
|
||||
* mark. `peek` reads without consuming; otherwise the mark advances so the next call hands out a
|
||||
* different address even before the current one is paid.
|
||||
*/
|
||||
private async nextUnused(type: AddressType, chain: ChainIndex, peek: boolean): Promise<AddressEntry> {
|
||||
const scan = await this.scan();
|
||||
const key = `${type}:${chain}`;
|
||||
const hint = this.issued.get(key) ?? 0;
|
||||
|
||||
const used = new Set<number>();
|
||||
let highest = -1;
|
||||
for (const entry of scan.addresses) {
|
||||
if (entry.type !== type || entry.chain !== chain) continue;
|
||||
highest = Math.max(highest, entry.index);
|
||||
if (entry.used) used.add(entry.index);
|
||||
}
|
||||
|
||||
let index = hint;
|
||||
// Anything past the scanned window is unused by definition — the gap limit is what ended the scan.
|
||||
while (index <= highest && used.has(index)) index++;
|
||||
|
||||
if (!peek) this.issued.set(key, index + 1);
|
||||
return this.derive(type, chain, index);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function toScannedAddress(entry: AddressEntry, stat: EsploraAddress): ScannedAddress {
|
||||
const chainBalance = stat.chain_stats.funded_txo_sum - stat.chain_stats.spent_txo_sum;
|
||||
// The mempool delta is signed: an unconfirmed spend of a confirmed coin reads negative here, which is
|
||||
// exactly what the Balances contract wants in onchainUnconfirmed.
|
||||
const mempoolBalance = stat.mempool_stats.funded_txo_sum - stat.mempool_stats.spent_txo_sum;
|
||||
const txCount = stat.chain_stats.tx_count + stat.mempool_stats.tx_count;
|
||||
return {
|
||||
...entry,
|
||||
confirmedSats: chainBalance,
|
||||
unconfirmedSats: mempoolBalance,
|
||||
txCount,
|
||||
used: txCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Varint, for the length prefix in the Bitcoin signed-message preimage. */
|
||||
function varint(n: number): Buffer {
|
||||
if (n < 0xfd) return Buffer.from([n]);
|
||||
if (n <= 0xffff) {
|
||||
const b = Buffer.alloc(3);
|
||||
b[0] = 0xfd;
|
||||
b.writeUInt16LE(n, 1);
|
||||
return b;
|
||||
}
|
||||
const b = Buffer.alloc(5);
|
||||
b[0] = 0xfe;
|
||||
b.writeUInt32LE(n, 1);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** sha256d(messagePrefix || varint(len) || message) — the standard signed-message preimage. */
|
||||
function bitcoinMessageHash(message: string, network: bitcoin.Network): Buffer {
|
||||
const prefix = Buffer.isBuffer(network.messagePrefix)
|
||||
? network.messagePrefix
|
||||
: Buffer.from(network.messagePrefix, 'utf8');
|
||||
const body = Buffer.from(message, 'utf8');
|
||||
return bitcoin.crypto.hash256(Buffer.concat([prefix, varint(body.length), body]));
|
||||
}
|
||||
|
||||
/** Bounded-concurrency map that preserves input order. Esplora is per-address, so scans fan out wide. */
|
||||
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let cursor = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
const i = cursor++;
|
||||
if (i >= items.length) return;
|
||||
const item = items[i];
|
||||
if (item === undefined) continue;
|
||||
out[i] = await fn(item);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user