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:
@@ -74,6 +74,8 @@ export type SidecarEvent =
|
||||
| { type: 'transmission:server'; port: number }
|
||||
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||
| { type: 'invoiceshelf:server'; port: number }
|
||||
// Wallet — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||
| { type: 'wallet:server'; port: number }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Vectors: the BOLT 11 spec examples (lightning/bolts, 11-payment-encoding.md § Examples), plus the
|
||||
// regtest and signet invoices Zeus uses in its own utils/Bolt11Utils.test.ts. Every spec invoice below is
|
||||
// signed with priv_key e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734.
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { clearBolt11Cache, decodeBolt11, decodeBolt11Invoice } from './bolt11';
|
||||
import { BackendError } from './types';
|
||||
|
||||
// ── spec vectors ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** no amount, description "Please consider supporting this project" */
|
||||
const NO_AMOUNT =
|
||||
'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql';
|
||||
|
||||
/** 2500u, description "1 cup coffee", expiry 60 */
|
||||
const COFFEE_250U =
|
||||
'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpu9qrsgquk0rl77nj30yxdy8j9vdx85fkpmdla2087ne0xh8nhedh8w27kyke0lp53ut353s06fv3qfegext0eh0ymjpf39tuven09sam30g4vgpfna3rh';
|
||||
|
||||
/** 2500u, UTF-8 description, expiry 60 */
|
||||
const NONSENSE_250U =
|
||||
'lnbc2500u1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpu9qrsgqhtjpauu9ur7fw2thcl4y9vfvh4m9wlfyz2gem29g5ghe2aak2pm3ps8fdhtceqsaagty2vph7utlgj48u0ged6a337aewvraedendscp573dxr';
|
||||
|
||||
/** 20m, description_hash instead of a description */
|
||||
const HASHED_20M =
|
||||
'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp7ynn44';
|
||||
|
||||
/** the same on testnet, with a P2PKH fallback address */
|
||||
const TESTNET_20M_FALLBACK =
|
||||
'lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8';
|
||||
|
||||
/** 20m with a fallback address and a two-hop route hint */
|
||||
const MAINNET_20M_ROUTES =
|
||||
'lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0';
|
||||
|
||||
/** 9678785340p — a msat amount that is not a whole number of sats */
|
||||
const PICO_9678785340P =
|
||||
'lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz';
|
||||
|
||||
/** 25m advertising features 8, 14 and 99 */
|
||||
const FEATURES_25M =
|
||||
'lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqqsgq2a25dxl5hrntdtn6zvydt7d66hyzsyhqs4wdynavys42xgl6sgx9c4g7me86a27t07mdtfry458rtjr0v92cnmswpsjscgt2vcse3sgpz3uapa';
|
||||
|
||||
/** the same invoice in the all-uppercase QR form */
|
||||
const FEATURES_25M_UPPER =
|
||||
'LNBC25M1PVJLUEZPP5QQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQQQSYQCYQ5RQWZQFQYPQDQ5VDHKVEN9V5SXYETPDEESSP5ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYG3ZYGS9Q5SQQQQQQQQQQQQQQQQSGQ2A25DXL5HRNTDTN6ZVYDT7D66HYZSYHQS4WDYNAVYS42XGL6SGX9C4G7ME86A27T07MDTFRY458RTJR0V92CNMSWPSJSCGT2VCSE3SGPZ3UAPA';
|
||||
|
||||
/** 10m carrying payment metadata 0x01fafaf0 */
|
||||
const METADATA_10M =
|
||||
'lnbc10m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdp9wpshjmt9de6zqmt9w3skgct5vysxjmnnd9jx2mq8q8a04uqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q2gqqqqqqsgq7hf8he7ecf7n4ffphs6awl9t6676rrclv9ckg3d3ncn7fct63p6s365duk5wrk202cfy3aj5xnnp5gs3vrdvruverwwq7yzhkf5a3xqpd05wjc';
|
||||
|
||||
/** a valid invoice whose signature is high-S */
|
||||
const HIGH_S_SIGNATURE =
|
||||
'lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap2r09nt4ndd0unm3z9u5t48y6ucv4r5sg7lk98c77ctvjczkspk5qprc90gx';
|
||||
|
||||
/** a high-S signature that does not match the invoice's own `n` payee field */
|
||||
const PAYEE_MISMATCH =
|
||||
'lnbc25m1p70xwfzpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaqnp4q0n326hr8v9zprg8gsvezcch06gfaqqhde2aj730yg0durunfhv66sp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsp5cfzp9ugllvk03rltd6hvndxj26ux6gcxc5azyxk060rj9tzghct5zvjlps76gx8wpq5yuu79688k8gnm2c0al6v608s96l0xzrrlqqwnzxmu';
|
||||
|
||||
/** bech32 checksum is invalid */
|
||||
const BAD_CHECKSUM =
|
||||
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt';
|
||||
|
||||
/** signature is not recoverable */
|
||||
const UNRECOVERABLE_SIGNATURE =
|
||||
'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqwgt7mcn5yqw3yx0w94pswkpq6j9uh6xfqqqtsk4tnarugeektd4hg5975x9am52rz4qskukxdmjemg92vvqz8nvmsye63r5ykel43pgz7zq0g2';
|
||||
|
||||
/** string is too short */
|
||||
const TOO_SHORT =
|
||||
'lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh';
|
||||
|
||||
/** invalid amount multiplier */
|
||||
const BAD_MULTIPLIER =
|
||||
'lnbc2500x1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgqrrzc4cvfue4zp3hggxp47ag7xnrlr8vgcmkjxk3j5jqethnumgkpqp23z9jclu3v0a7e0aruz366e9wqdykw6dxhdzcjjhldxq0w6wgqcnu43j';
|
||||
|
||||
/** invalid sub-millisatoshi precision */
|
||||
const SUB_MSAT_PRECISION =
|
||||
'lnbc2500000001p1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uv0m3umg28gclm5lqxtqqwk32uuk4k6673k6n5kfvx3d2h8s295fad45fdhmusm8sjudfhlf6dcsxmfvkeywmjdkxcp99202x';
|
||||
|
||||
// ── Zeus fixtures (utils/Bolt11Utils.test.ts) ────────────────────────────────────────────────────
|
||||
|
||||
/** regtest, 1230n = 123 sat, no expiry field */
|
||||
const REGTEST_1230N =
|
||||
'lnbcrt1230n1pj429x7pp57t97q4awqj3f529snr0pa6senk83sq5pp760qf5a4jzvd7xgwcksdqqcqzzsxqrrsssp57eqtv7vxr46arupna3w4ct0lkf2mqmz9wt044cwkks0rwlnhfr5s9qyyssqragwpwav7nfwv2xyuuamxxj4pnnpzv2hlw7j473repd3sq7st698ta9kmzmygt0w7tmncl56a6mnma0w7e5dlpqd0wy6x3v35rssldspjhh8p0';
|
||||
|
||||
/** signet, 567780n = 56778 sat */
|
||||
const SIGNET_567780N =
|
||||
'lntbs567780n1pnqr26ypp5c0wcrpzwxwqnwu2nld5q36dfc9yjrfdp87nn9d5y093jjncvqresdq0w3jhxar8v3n8xeccqzpuxqrrsssp5r94e3nwnw63gjaxc8wex38ufv2m6442vnrw49m7dad9jdum3tdsq9qyyssqk4dvvuk7zhhju8ztf7nfc2hzqq9gqtzuyc0ljz8nl93laxwv4869lt9fsxkxacje6eh4ur5ymg83hvakn4tfpzdu6fq49705sar7fxspga8qjp';
|
||||
|
||||
/** Every spec vector shares this payee, payment hash and timestamp. */
|
||||
const SPEC_PAYEE = '03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad';
|
||||
const SPEC_PAYMENT_HASH = '0001020304050607080900010203040506070809000102030405060708090102';
|
||||
const SPEC_TIMESTAMP = 1496314658;
|
||||
// ── amounts ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 amounts', () => {
|
||||
it('returns null for a zero-amount invoice', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).amountMsat).toBeNull();
|
||||
});
|
||||
|
||||
it('parses every multiplier as an exact msat string', () => {
|
||||
// 20m = 0.02 BTC, 2500u = 0.0025 BTC, 1230n = 123 sat, 9678785340p = 967878534 msat.
|
||||
expect(decodeBolt11(HASHED_20M).amountMsat).toBe('2000000000');
|
||||
expect(decodeBolt11(COFFEE_250U).amountMsat).toBe('250000000');
|
||||
expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000');
|
||||
expect(decodeBolt11(PICO_9678785340P).amountMsat).toBe('967878534');
|
||||
});
|
||||
|
||||
it('keeps amountMsat a string and never widens it to a number', () => {
|
||||
const amount = decodeBolt11(PICO_9678785340P).amountMsat;
|
||||
expect(typeof amount).toBe('string');
|
||||
// A pico amount need not be a whole sat: the msat string is exact where `satoshis` cannot be.
|
||||
expect(decodeBolt11Invoice(PICO_9678785340P).satoshis).toBeNull();
|
||||
expect(decodeBolt11Invoice(REGTEST_1230N).satoshis).toBe(123);
|
||||
});
|
||||
|
||||
it('rejects an unknown multiplier', () => {
|
||||
expect(() => decodeBolt11(BAD_MULTIPLIER)).toThrow(BackendError);
|
||||
});
|
||||
|
||||
it('rejects sub-millisatoshi precision instead of rounding it', () => {
|
||||
expect(() => decodeBolt11(SUB_MSAT_PRECISION)).toThrow(BackendError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── fields ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 fields', () => {
|
||||
it('extracts the payment hash, destination and timestamp', () => {
|
||||
const decoded = decodeBolt11(NO_AMOUNT);
|
||||
expect(decoded.paymentHash).toBe(SPEC_PAYMENT_HASH);
|
||||
expect(decoded.destination).toBe(SPEC_PAYEE);
|
||||
expect(decoded.timestamp).toBe(SPEC_TIMESTAMP);
|
||||
});
|
||||
|
||||
it('recovers a key from a high-S signature instead of rejecting it', () => {
|
||||
// The spec's high-S vector is the donation invoice re-signed with s negated while keeping recovery
|
||||
// flag 1, so it recovers a *different* (valid) key. What matters is that recovery runs at all —
|
||||
// implementations that enforce low-S during recovery fail here.
|
||||
expect(decodeBolt11(HIGH_S_SIGNATURE).destination).toMatch(/^0[23][0-9a-f]{64}$/);
|
||||
expect(decodeBolt11(HIGH_S_SIGNATURE).destination).not.toBe(SPEC_PAYEE);
|
||||
expect(decodeBolt11(HIGH_S_SIGNATURE).paymentHash).toBe(SPEC_PAYMENT_HASH);
|
||||
});
|
||||
|
||||
it('returns the description for a `d` invoice and null for an `h` one', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).description).toBe('Please consider supporting this project');
|
||||
expect(decodeBolt11(COFFEE_250U).description).toBe('1 cup coffee');
|
||||
expect(decodeBolt11(NONSENSE_250U).description).toBe('ナンセンス 1杯');
|
||||
|
||||
const hashed = decodeBolt11Invoice(HASHED_20M);
|
||||
expect(decodeBolt11(HASHED_20M).description).toBeNull();
|
||||
expect(hashed.descriptionHash).toBe('3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1');
|
||||
});
|
||||
|
||||
it('defaults expiry to 3600 when there is no `x` field', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).expiry).toBe(3600);
|
||||
expect(decodeBolt11Invoice(NO_AMOUNT).expiry).toBeNull();
|
||||
expect(decodeBolt11(REGTEST_1230N).expiry).toBe(3600);
|
||||
});
|
||||
|
||||
it('reads an explicit expiry', () => {
|
||||
expect(decodeBolt11(COFFEE_250U).expiry).toBe(60);
|
||||
expect(decodeBolt11(PICO_9678785340P).expiry).toBe(604800);
|
||||
expect(decodeBolt11Invoice(COFFEE_250U).expiresAt).toBe(SPEC_TIMESTAMP + 60);
|
||||
});
|
||||
|
||||
it('expands the feature bitmap to the indices of the set bits', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).features).toEqual(['8', '14']);
|
||||
expect(decodeBolt11(FEATURES_25M).features).toEqual(['8', '14', '99']);
|
||||
});
|
||||
|
||||
it('reports route hints and parses their hops', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).routeHints).toBe(false);
|
||||
|
||||
const decoded = decodeBolt11Invoice(MAINNET_20M_ROUTES);
|
||||
expect(decodeBolt11(MAINNET_20M_ROUTES).routeHints).toBe(true);
|
||||
expect(decoded.routes).toHaveLength(1);
|
||||
expect(decoded.routes[0]).toEqual([
|
||||
{
|
||||
pubkey: '029e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255',
|
||||
shortChannelId: '66051x263430x1800',
|
||||
feeBaseMsat: 1,
|
||||
feeProportionalMillionths: 20,
|
||||
cltvExpiryDelta: 3,
|
||||
},
|
||||
{
|
||||
pubkey: '039e03a901b85534ff1e92c43c74431f7ce72046060fcf7a95c37e148f78c77255',
|
||||
shortChannelId: '197637x395016x2314',
|
||||
feeBaseMsat: 2,
|
||||
feeProportionalMillionths: 30,
|
||||
cltvExpiryDelta: 4,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses payment secret, metadata and fallback addresses', () => {
|
||||
expect(decodeBolt11Invoice(NO_AMOUNT).paymentSecret).toBe(
|
||||
'1111111111111111111111111111111111111111111111111111111111111111',
|
||||
);
|
||||
expect(decodeBolt11Invoice(METADATA_10M).metadata).toBe('01fafaf0');
|
||||
|
||||
const fallback = decodeBolt11Invoice(TESTNET_20M_FALLBACK).fallbacks[0];
|
||||
// 17 is the P2PKH marker; the program is the 20-byte hash160 of mk2QpYatsKicvFVuTAQLBryyccRXMUaGHP.
|
||||
expect(fallback?.version).toBe(17);
|
||||
expect(fallback?.programHex).toHaveLength(40);
|
||||
});
|
||||
|
||||
it('leaves cltvExpiry null when the invoice omits `c`', () => {
|
||||
expect(decodeBolt11(NO_AMOUNT).cltvExpiry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── networks ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 networks', () => {
|
||||
it('resolves every bech32 network prefix', () => {
|
||||
expect(decodeBolt11Invoice(NO_AMOUNT).network).toBe('bitcoin');
|
||||
expect(decodeBolt11Invoice(TESTNET_20M_FALLBACK).network).toBe('testnet');
|
||||
expect(decodeBolt11Invoice(SIGNET_567780N).network).toBe('signet');
|
||||
expect(decodeBolt11Invoice(REGTEST_1230N).network).toBe('regtest');
|
||||
});
|
||||
|
||||
it('parses the amount out of a multi-letter network prefix', () => {
|
||||
// `lnbcrt1230n` and `lntbs567780n` are the cases where a greedy hrp match eats the amount.
|
||||
expect(decodeBolt11(REGTEST_1230N).amountMsat).toBe('123000');
|
||||
expect(decodeBolt11(SIGNET_567780N).amountMsat).toBe('56778000');
|
||||
});
|
||||
|
||||
it('rejects an unknown network prefix', () => {
|
||||
expect(() => decodeBolt11(NO_AMOUNT.replace(/^lnbc/, 'lnxyz'))).toThrow(BackendError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── malformed input ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('decodeBolt11 rejections', () => {
|
||||
const expectBadInvoice = (invoice: string) => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
decodeBolt11(invoice);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(BackendError);
|
||||
expect((caught as BackendError).status).toBe(400);
|
||||
expect((caught as BackendError).code).toBe('BAD_INVOICE');
|
||||
};
|
||||
|
||||
it('throws BackendError(400, BAD_INVOICE) on a corrupted checksum', () => {
|
||||
expectBadInvoice(BAD_CHECKSUM);
|
||||
// The same invoice with one data character flipped.
|
||||
expectBadInvoice(`${COFFEE_250U.slice(0, -1)}${COFFEE_250U.endsWith('h') ? 'w' : 'h'}`);
|
||||
});
|
||||
|
||||
it('throws on a signature that cannot be recovered', () => {
|
||||
expectBadInvoice(UNRECOVERABLE_SIGNATURE);
|
||||
});
|
||||
|
||||
it('throws when the recovered key contradicts an explicit `n` payee field', () => {
|
||||
expectBadInvoice(PAYEE_MISMATCH);
|
||||
});
|
||||
|
||||
it('throws on a truncated payment request', () => {
|
||||
expectBadInvoice(TOO_SHORT);
|
||||
});
|
||||
|
||||
it('throws on input that is not a payment request at all', () => {
|
||||
expectBadInvoice('');
|
||||
expectBadInvoice('not an invoice');
|
||||
expectBadInvoice(NO_AMOUNT.replace(/^ln/, ''));
|
||||
});
|
||||
|
||||
it('throws on a mixed-case payment request but accepts the uppercase QR form', () => {
|
||||
expectBadInvoice(`LNBC${NO_AMOUNT.slice(4)}`);
|
||||
expect(decodeBolt11(FEATURES_25M_UPPER)).toEqual(decodeBolt11(FEATURES_25M));
|
||||
// The contract's `bolt11` is always the canonical lowercase form.
|
||||
expect(decodeBolt11(FEATURES_25M_UPPER).bolt11).toBe(FEATURES_25M);
|
||||
});
|
||||
});
|
||||
|
||||
// ── cache ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('the LRU cache', () => {
|
||||
it('returns the same object for a repeat decode and survives a clear', () => {
|
||||
clearBolt11Cache();
|
||||
const first = decodeBolt11Invoice(COFFEE_250U);
|
||||
expect(decodeBolt11Invoice(COFFEE_250U)).toBe(first);
|
||||
expect(decodeBolt11Invoice(COFFEE_250U.toUpperCase())).toBe(first);
|
||||
|
||||
clearBolt11Cache();
|
||||
const afterClear = decodeBolt11Invoice(COFFEE_250U);
|
||||
expect(afterClear).not.toBe(first);
|
||||
expect(afterClear).toEqual(first);
|
||||
});
|
||||
|
||||
it('hands every decodeBolt11 caller its own object', () => {
|
||||
const first = decodeBolt11(COFFEE_250U);
|
||||
const second = decodeBolt11(COFFEE_250U);
|
||||
expect(second).not.toBe(first);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,516 @@
|
||||
// BOLT11 invoice decoding, in-process — no node round-trip.
|
||||
//
|
||||
// Ported from Zeus's `utils/Bolt11Utils.ts`, which is itself derived from **light-bolt11-decoder**:
|
||||
// Copyright (c) 2021 bitcoinjs contributors, fiatjaf. MIT licence.
|
||||
// The bech32 walk, the human-readable-part amount grammar and the signature preimage construction are
|
||||
// that upstream's work and are preserved verbatim in behaviour.
|
||||
//
|
||||
// INTENTIONAL DIFFERENCES from Zeus's port:
|
||||
//
|
||||
// 1. ERRORS. Zeus mixes `throw new Error(...)` with silent `undefined` returns (a bad multiplier sets
|
||||
// `satoshis = null` and carries on; a `fromWordsUnsafe` failure yields `undefined`). Every failure
|
||||
// here is a `BackendError(msg, 400, 'BAD_INVOICE')`, so routes.ts answers 400 rather than leaking a
|
||||
// half-decoded invoice with null amounts into a payment flow.
|
||||
// 2. NO bignumber.js. All amount arithmetic is BigInt: msat tops out at 2.1e18, which a double cannot
|
||||
// hold, and BigInt does the same job exactly with one less dependency.
|
||||
// 3. NO @noble/hashes. The single sha256 comes from `node:crypto`, which Bun provides natively.
|
||||
// 4. EAGER signature recovery. Zeus installs lazy getters for `destination`/`signature` because its
|
||||
// Activity list decodes hundreds of invoices to read only the timestamp. Here `DecodedInvoice`
|
||||
// requires a non-null `destination` on every call, so laziness would never pay off; the LRU cache
|
||||
// (which is what actually collapses repeat recoveries) is kept and sized for a server.
|
||||
// 5. NO `sections` array. Zeus keeps light-bolt11-decoder's positional `{name, letters, value?: any}`
|
||||
// list for back-compat. Nothing here consumes it, and it was the source of the `any`. Every tag is
|
||||
// exposed as a named, typed field instead, with anything unrecognised in `unknownTags`.
|
||||
// 6. MORE TAGS PARSED. Zeus leaves `r` (route hints), `9` (features) and `f` (fallback address) as
|
||||
// unknown tags. All three are parsed, because the contract's `routeHints` and `features` fields need
|
||||
// them. Feature bits are expanded to the indices of the set bits, matching how backends/clnrest.ts
|
||||
// renders CLN's bitmap.
|
||||
// 7. SPEC-CONFORMANT LENIENCY/STRICTNESS. Known tags whose `data_length` is wrong are skipped and
|
||||
// unknown tags ignored (BOLT 11 requires both). An all-uppercase invoice — the QR form — is accepted
|
||||
// and lowercased; a MIXED-case one is rejected, as the spec requires and Zeus's blanket
|
||||
// `.toLowerCase()` did not. An explicit `n` payee is checked against the recovered key rather than
|
||||
// trusted blindly, which is the check Zeus skips.
|
||||
// 8. NO SIMNET. Zeus carries the btcd-only `sb` prefix; `BitcoinNetwork` has no member for it.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { bech32 } from 'bech32';
|
||||
import { recoverPublicKey } from '@noble/secp256k1';
|
||||
import { BackendError, type BitcoinNetwork, type DecodedInvoice } from './types';
|
||||
|
||||
// ── shape ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Bolt11NetworkParams = {
|
||||
/** The bech32 human-readable part that follows `ln`. */
|
||||
readonly bech32: string;
|
||||
readonly network: BitcoinNetwork;
|
||||
readonly pubKeyHash: number;
|
||||
readonly scriptHash: number;
|
||||
readonly validWitnessVersions: readonly number[];
|
||||
};
|
||||
|
||||
/** One hop of an `r` field. Multiple `r` fields each describe a separate route. */
|
||||
export type Bolt11RouteHop = {
|
||||
readonly pubkey: string;
|
||||
/** `block x tx x output`, the standard short_channel_id rendering. */
|
||||
readonly shortChannelId: string;
|
||||
readonly feeBaseMsat: number;
|
||||
readonly feeProportionalMillionths: number;
|
||||
readonly cltvExpiryDelta: number;
|
||||
};
|
||||
|
||||
/** An `f` field. Left as witness-version + program rather than re-encoded to an address string. */
|
||||
export type Bolt11Fallback = {
|
||||
/** 0-16 are segwit witness versions; 17 is P2PKH and 18 is P2SH. */
|
||||
readonly version: number;
|
||||
readonly programHex: string;
|
||||
};
|
||||
|
||||
export type Bolt11UnknownTag = { readonly tagCode: number; readonly words: readonly number[] };
|
||||
|
||||
/** The full decode. `decodeBolt11` projects this onto the narrower `DecodedInvoice` contract. */
|
||||
export type Bolt11Invoice = {
|
||||
/** The invoice, lowercased. */
|
||||
readonly paymentRequest: string;
|
||||
/** The whole human-readable part, e.g. `lnbc2500u`. */
|
||||
readonly prefix: string;
|
||||
readonly network: BitcoinNetwork;
|
||||
readonly networkParams: Bolt11NetworkParams;
|
||||
readonly timestamp: number;
|
||||
/** Decimal msat string, or null for a zero-amount ("any amount") invoice. Never a number. */
|
||||
readonly millisatoshis: string | null;
|
||||
/** Convenience only, and null whenever the amount is not a whole number of sats. */
|
||||
readonly satoshis: number | null;
|
||||
readonly paymentHash: string;
|
||||
readonly paymentSecret: string | null;
|
||||
readonly description: string | null;
|
||||
readonly descriptionHash: string | null;
|
||||
/** The explicit `n` payee field, when the invoice carries one. */
|
||||
readonly payee: string | null;
|
||||
/** `payee` when present, else recovered from the signature. 33-byte compressed pubkey, hex. */
|
||||
readonly destination: string;
|
||||
/** The raw `x` field; null when absent. `expirySeconds` applies the spec's 3600 default. */
|
||||
readonly expiry: number | null;
|
||||
readonly expirySeconds: number;
|
||||
readonly expiresAt: number;
|
||||
readonly cltvExpiry: number | null;
|
||||
readonly metadata: string | null;
|
||||
/** Indices of the set feature bits, as decimal strings. */
|
||||
readonly featureBits: readonly string[];
|
||||
readonly routes: readonly (readonly Bolt11RouteHop[])[];
|
||||
readonly fallbacks: readonly Bolt11Fallback[];
|
||||
/** 64-byte compact signature, hex. */
|
||||
readonly signature: string;
|
||||
readonly recoveryFlag: number;
|
||||
readonly unknownTags: readonly Bolt11UnknownTag[];
|
||||
};
|
||||
|
||||
// ── constants ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Zeus caches 256 entries for a phone's Activity list. A server decodes the same invoice from several
|
||||
* call sites (list → detail → pay), so the cache earns its keep the same way; 1024 entries of a few
|
||||
* hundred bytes each is a rounding error against a Bun heap.
|
||||
*/
|
||||
const CACHE_LIMIT = 1024;
|
||||
const cache = new Map<string, Bolt11Invoice>();
|
||||
|
||||
const MSAT_PER_BTC = 100_000_000_000n;
|
||||
const MAX_MSAT = 2_100_000_000_000_000_000n;
|
||||
const DIVISORS: Readonly<Record<string, bigint>> = {
|
||||
m: 1_000n,
|
||||
u: 1_000_000n,
|
||||
n: 1_000_000_000n,
|
||||
p: 1_000_000_000_000n,
|
||||
};
|
||||
|
||||
const NETWORKS: readonly Bolt11NetworkParams[] = [
|
||||
{ bech32: 'bc', network: 'bitcoin', pubKeyHash: 0x00, scriptHash: 0x05, validWitnessVersions: [0, 1] },
|
||||
{ bech32: 'tb', network: 'testnet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
|
||||
{ bech32: 'tbs', network: 'signet', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
|
||||
{ bech32: 'bcrt', network: 'regtest', pubKeyHash: 0x6f, scriptHash: 0xc4, validWitnessVersions: [0, 1] },
|
||||
];
|
||||
|
||||
const TAG_NAMES = {
|
||||
1: 'payment_hash',
|
||||
3: 'route_hint',
|
||||
5: 'feature_bits',
|
||||
6: 'expiry',
|
||||
9: 'fallback_address',
|
||||
13: 'description',
|
||||
16: 'payment_secret',
|
||||
19: 'payee',
|
||||
23: 'description_hash',
|
||||
24: 'min_final_cltv_expiry',
|
||||
27: 'metadata',
|
||||
} as const satisfies Record<number, string>;
|
||||
|
||||
/** BOLT 11: a reader MUST skip a `p`, `s`, `h` or `n` field whose data_length is not the fixed size. */
|
||||
const FIXED_TAG_WORDS: Readonly<Record<number, number>> = { 1: 52, 16: 52, 23: 52, 19: 53 };
|
||||
|
||||
/** The signature occupies the last 104 words — 65 bytes: 64-byte compact sig + 1 recovery byte. */
|
||||
const SIGNATURE_WORDS = 104;
|
||||
const TIMESTAMP_WORDS = 7;
|
||||
|
||||
/** BOLT 11 default when no `x` field is present. */
|
||||
const DEFAULT_EXPIRY_SECONDS = 3600;
|
||||
|
||||
const ROUTE_HOP_BYTES = 51;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function badInvoice(message: string): never {
|
||||
throw new BackendError(`invalid bolt11 invoice: ${message}`, 400, 'BAD_INVOICE');
|
||||
}
|
||||
|
||||
/** Big-endian base-32. BigInt because a hostile tag can claim far more than 53 bits. */
|
||||
function wordsToInt(words: readonly number[]): number {
|
||||
let value = 0n;
|
||||
for (const word of words) value = value * 32n + BigInt(word);
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER)) badInvoice('a numeric field is out of range');
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function wordsToBytes(words: readonly number[]): Uint8Array {
|
||||
const bytes = bech32.fromWordsUnsafe(words);
|
||||
if (bytes == null) badInvoice('a field is not a whole number of bytes');
|
||||
return Uint8Array.from(bytes);
|
||||
}
|
||||
|
||||
const wordsToHex = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('hex');
|
||||
|
||||
const wordsToUtf8 = (words: readonly number[]): string => Buffer.from(wordsToBytes(words)).toString('utf8');
|
||||
|
||||
/**
|
||||
* The `9` field is a big-endian bit vector whose LAST bit is feature 0, so a word's position from the
|
||||
* end fixes the base index. Rendered as decimal strings to match backends/clnrest.ts's `featureBits`.
|
||||
*/
|
||||
function wordsToFeatureBits(words: readonly number[]): string[] {
|
||||
const bits: string[] = [];
|
||||
for (let i = words.length - 1; i >= 0; i--) {
|
||||
const word = words[i] ?? 0;
|
||||
const base = (words.length - 1 - i) * 5;
|
||||
for (let bit = 0; bit < 5; bit++) {
|
||||
if (word & (1 << bit)) bits.push(String(base + bit));
|
||||
}
|
||||
}
|
||||
return bits.sort((a, b) => Number(a) - Number(b));
|
||||
}
|
||||
|
||||
function wordsToRoute(words: readonly number[]): Bolt11RouteHop[] {
|
||||
const bytes = Buffer.from(wordsToBytes(words));
|
||||
if (bytes.length === 0 || bytes.length % ROUTE_HOP_BYTES !== 0) badInvoice('a route hint is malformed');
|
||||
|
||||
const hops: Bolt11RouteHop[] = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += ROUTE_HOP_BYTES) {
|
||||
const hop = bytes.subarray(offset, offset + ROUTE_HOP_BYTES);
|
||||
const scid = hop.subarray(33, 41);
|
||||
const block = scid.readUIntBE(0, 3);
|
||||
const tx = scid.readUIntBE(3, 3);
|
||||
const output = scid.readUInt16BE(6);
|
||||
hops.push({
|
||||
pubkey: hop.subarray(0, 33).toString('hex'),
|
||||
shortChannelId: `${block}x${tx}x${output}`,
|
||||
feeBaseMsat: hop.readUInt32BE(41),
|
||||
feeProportionalMillionths: hop.readUInt32BE(45),
|
||||
cltvExpiryDelta: hop.readUInt16BE(49),
|
||||
});
|
||||
}
|
||||
return hops;
|
||||
}
|
||||
|
||||
function wordsToFallback(words: readonly number[]): Bolt11Fallback | null {
|
||||
const version = words[0];
|
||||
// A fallback with no program is meaningless; the spec says to ignore an unparseable one.
|
||||
if (version === undefined || words.length < 2) return null;
|
||||
return { version, programHex: wordsToHex(words.slice(1)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The amount grammar from the human-readable part: `<digits><multiplier?>`, where the multiplier
|
||||
* divides one bitcoin. Integer arithmetic throughout — a `p` amount that is not a multiple of 10 would
|
||||
* be sub-millisatoshi and is invalid rather than rounded.
|
||||
*/
|
||||
function hrpToMsat(amount: string, multiplier: string): string {
|
||||
if (!/^\d+$/.test(amount)) badInvoice(`"${amount}" is not a valid amount`);
|
||||
if (multiplier && !(multiplier in DIVISORS)) badInvoice(`"${multiplier}" is not a valid amount multiplier`);
|
||||
|
||||
const value = BigInt(amount);
|
||||
const divisor = multiplier ? DIVISORS[multiplier] : undefined;
|
||||
const msat = divisor === undefined ? value * MSAT_PER_BTC : (value * MSAT_PER_BTC) / divisor;
|
||||
|
||||
if (multiplier === 'p' && value % 10n !== 0n) badInvoice('amount has sub-millisatoshi precision');
|
||||
if (msat > MAX_MSAT) badInvoice('amount is outside of valid range');
|
||||
return msat.toString();
|
||||
}
|
||||
|
||||
/** 5-bit → 8-bit, right-padded with zeros: the preimage the signature covers. */
|
||||
function convertBits(words: readonly number[]): Uint8Array {
|
||||
let value = 0;
|
||||
let bits = 0;
|
||||
const result: number[] = [];
|
||||
for (const word of words) {
|
||||
value = (value << 5) | word;
|
||||
bits += 5;
|
||||
while (bits >= 8) {
|
||||
bits -= 8;
|
||||
result.push((value >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
if (bits > 0) result.push((value << (8 - bits)) & 0xff);
|
||||
return Uint8Array.from(result);
|
||||
}
|
||||
|
||||
type Recovered = { destination: string; signature: string; recoveryFlag: number };
|
||||
|
||||
/** The signed message is sha256( utf8(prefix) || convertBits(dataWords) ). SEC1 recovery from there. */
|
||||
function recoverPayee(prefix: string, sigWords: readonly number[], signedWords: readonly number[]): Recovered {
|
||||
const sigBytes = wordsToBytes(sigWords);
|
||||
const recoveryFlag = sigBytes[64];
|
||||
if (sigBytes.length !== 65 || recoveryFlag === undefined || recoveryFlag > 3) {
|
||||
badInvoice('signature is malformed');
|
||||
}
|
||||
|
||||
const preimage = Buffer.concat([Buffer.from(prefix, 'utf8'), Buffer.from(convertBits(signedWords))]);
|
||||
const hash = createHash('sha256').update(preimage).digest();
|
||||
|
||||
// @noble/secp256k1 v3 wants the recovery byte FIRST; bech32 carries it last.
|
||||
const recoverable = Buffer.concat([Buffer.from([recoveryFlag]), Buffer.from(sigBytes.subarray(0, 64))]);
|
||||
|
||||
let pubkey: Uint8Array;
|
||||
try {
|
||||
pubkey = recoverPublicKey(recoverable, hash, { prehash: false });
|
||||
} catch {
|
||||
badInvoice('signature is not recoverable');
|
||||
}
|
||||
|
||||
return {
|
||||
destination: Buffer.from(pubkey).toString('hex'),
|
||||
signature: Buffer.from(sigBytes.subarray(0, 64)).toString('hex'),
|
||||
recoveryFlag,
|
||||
};
|
||||
}
|
||||
|
||||
/** `lnbc2500u` → the network params and the amount. */
|
||||
function parsePrefix(prefix: string): { params: Bolt11NetworkParams; millisatoshis: string | null } {
|
||||
// Non-greedy on the hrp so the trailing digits+multiplier win: `lnbcrt500u` → bcrt / 500 / u. A prefix
|
||||
// with no amount ends in a letter the first pattern would eat, hence the amount-less second attempt.
|
||||
let matches = /^ln(\S+?)(\d+)([a-z]?)$/.exec(prefix);
|
||||
let amount = matches?.[2] ?? '';
|
||||
let multiplier = matches?.[3] ?? '';
|
||||
if (!matches) {
|
||||
matches = /^ln(\S+)$/.exec(prefix);
|
||||
amount = '';
|
||||
multiplier = '';
|
||||
}
|
||||
|
||||
const hrp = matches?.[1];
|
||||
if (!hrp) badInvoice('not a lightning payment request');
|
||||
|
||||
const params = NETWORKS.find((candidate) => candidate.bech32 === hrp);
|
||||
if (!params) badInvoice(`unknown network prefix "ln${hrp}"`);
|
||||
|
||||
return { params, millisatoshis: amount ? hrpToMsat(amount, multiplier) : null };
|
||||
}
|
||||
|
||||
type Tags = {
|
||||
paymentHash: string | null;
|
||||
paymentSecret: string | null;
|
||||
description: string | null;
|
||||
descriptionHash: string | null;
|
||||
payee: string | null;
|
||||
expiry: number | null;
|
||||
cltvExpiry: number | null;
|
||||
metadata: string | null;
|
||||
featureBits: string[];
|
||||
routes: Bolt11RouteHop[][];
|
||||
fallbacks: Bolt11Fallback[];
|
||||
unknownTags: Bolt11UnknownTag[];
|
||||
};
|
||||
|
||||
/** Walks the tagged fields. First occurrence of a field wins; unknown and wrong-length fields are skipped. */
|
||||
function parseTags(dataWords: readonly number[]): Tags {
|
||||
const tags: Tags = {
|
||||
paymentHash: null,
|
||||
paymentSecret: null,
|
||||
description: null,
|
||||
descriptionHash: null,
|
||||
payee: null,
|
||||
expiry: null,
|
||||
cltvExpiry: null,
|
||||
metadata: null,
|
||||
featureBits: [],
|
||||
routes: [],
|
||||
fallbacks: [],
|
||||
unknownTags: [],
|
||||
};
|
||||
|
||||
let words = dataWords;
|
||||
while (words.length > 0) {
|
||||
const tagCode = words[0];
|
||||
if (tagCode === undefined) break;
|
||||
if (words.length < 3) badInvoice('a tagged field is truncated');
|
||||
|
||||
const length = wordsToInt(words.slice(1, 3));
|
||||
words = words.slice(3);
|
||||
if (length > words.length) badInvoice('a tagged field overruns the invoice');
|
||||
const tagWords = words.slice(0, length);
|
||||
words = words.slice(length);
|
||||
|
||||
const expected = FIXED_TAG_WORDS[tagCode];
|
||||
if (expected !== undefined && length !== expected) continue;
|
||||
|
||||
const name: string | undefined = TAG_NAMES[tagCode as keyof typeof TAG_NAMES];
|
||||
switch (name) {
|
||||
case 'payment_hash':
|
||||
tags.paymentHash ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'payment_secret':
|
||||
tags.paymentSecret ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'description':
|
||||
tags.description ??= wordsToUtf8(tagWords);
|
||||
break;
|
||||
case 'description_hash':
|
||||
tags.descriptionHash ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'payee':
|
||||
tags.payee ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'expiry':
|
||||
tags.expiry ??= wordsToInt(tagWords);
|
||||
break;
|
||||
case 'min_final_cltv_expiry':
|
||||
tags.cltvExpiry ??= wordsToInt(tagWords);
|
||||
break;
|
||||
case 'metadata':
|
||||
tags.metadata ??= wordsToHex(tagWords);
|
||||
break;
|
||||
case 'feature_bits':
|
||||
if (tags.featureBits.length === 0) tags.featureBits = wordsToFeatureBits(tagWords);
|
||||
break;
|
||||
case 'route_hint':
|
||||
tags.routes.push(wordsToRoute(tagWords));
|
||||
break;
|
||||
case 'fallback_address': {
|
||||
const fallback = wordsToFallback(tagWords);
|
||||
if (fallback) tags.fallbacks.push(fallback);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
tags.unknownTags.push({ tagCode, words: tagWords });
|
||||
}
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
// ── the decoder ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Full decode, cached. The returned object is shared with every other caller for the same invoice and is
|
||||
* typed readonly throughout — treat it as frozen.
|
||||
*/
|
||||
export function decodeBolt11Invoice(paymentRequest: string): Bolt11Invoice {
|
||||
if (typeof paymentRequest !== 'string') badInvoice('expected a string');
|
||||
|
||||
const trimmed = paymentRequest.trim();
|
||||
// The QR form is all-uppercase and legal; a mixed-case string is not (BOLT 11 / BIP-173).
|
||||
if (/[a-z]/.test(trimmed) && /[A-Z]/.test(trimmed)) badInvoice('mixed-case payment request');
|
||||
const normalized = trimmed.toLowerCase();
|
||||
|
||||
const cached = cache.get(normalized);
|
||||
if (cached) {
|
||||
// Re-insert to move to the MRU end of the Map's insertion order.
|
||||
cache.delete(normalized);
|
||||
cache.set(normalized, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!normalized.startsWith('ln')) badInvoice('not a lightning payment request');
|
||||
|
||||
let decoded: { prefix: string; words: number[] };
|
||||
try {
|
||||
decoded = bech32.decode(normalized, Number.MAX_SAFE_INTEGER);
|
||||
} catch (err) {
|
||||
badInvoice(err instanceof Error ? err.message.toLowerCase() : 'bech32 decode failed');
|
||||
}
|
||||
|
||||
if (decoded.words.length < SIGNATURE_WORDS + TIMESTAMP_WORDS) badInvoice('payment request is too short');
|
||||
|
||||
const { params, millisatoshis } = parsePrefix(decoded.prefix);
|
||||
|
||||
const signedWords = decoded.words.slice(0, -SIGNATURE_WORDS);
|
||||
const sigWords = decoded.words.slice(-SIGNATURE_WORDS);
|
||||
const timestamp = wordsToInt(signedWords.slice(0, TIMESTAMP_WORDS));
|
||||
const tags = parseTags(signedWords.slice(TIMESTAMP_WORDS));
|
||||
|
||||
if (!tags.paymentHash) badInvoice('no payment hash');
|
||||
|
||||
const recovered = recoverPayee(decoded.prefix, sigWords, signedWords);
|
||||
// BOLT 11: when an `n` field is present a reader MUST still check the signature against it. A correct
|
||||
// signer always recovers to its own key, so a mismatch means the invoice was tampered with.
|
||||
if (tags.payee && tags.payee !== recovered.destination) badInvoice('signature does not match the payee');
|
||||
|
||||
const msat = millisatoshis === null ? null : BigInt(millisatoshis);
|
||||
const expirySeconds = tags.expiry ?? DEFAULT_EXPIRY_SECONDS;
|
||||
|
||||
const invoice: Bolt11Invoice = {
|
||||
paymentRequest: normalized,
|
||||
prefix: decoded.prefix,
|
||||
network: params.network,
|
||||
networkParams: params,
|
||||
timestamp,
|
||||
millisatoshis,
|
||||
satoshis: msat !== null && msat % 1000n === 0n ? Number(msat / 1000n) : null,
|
||||
paymentHash: tags.paymentHash,
|
||||
paymentSecret: tags.paymentSecret,
|
||||
description: tags.description,
|
||||
descriptionHash: tags.descriptionHash,
|
||||
payee: tags.payee,
|
||||
destination: tags.payee ?? recovered.destination,
|
||||
expiry: tags.expiry,
|
||||
expirySeconds,
|
||||
expiresAt: timestamp + expirySeconds,
|
||||
cltvExpiry: tags.cltvExpiry,
|
||||
metadata: tags.metadata,
|
||||
featureBits: tags.featureBits,
|
||||
routes: tags.routes,
|
||||
fallbacks: tags.fallbacks,
|
||||
signature: recovered.signature,
|
||||
recoveryFlag: recovered.recoveryFlag,
|
||||
unknownTags: tags.unknownTags,
|
||||
};
|
||||
|
||||
if (cache.size >= CACHE_LIMIT) {
|
||||
const oldest = cache.keys().next();
|
||||
if (!oldest.done) cache.delete(oldest.value);
|
||||
}
|
||||
cache.set(normalized, invoice);
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* The contract surface: a BOLT11 payment request as the backends report it. Throws
|
||||
* `BackendError(…, 400, 'BAD_INVOICE')` for anything that does not decode.
|
||||
*/
|
||||
export function decodeBolt11(bolt11: string): DecodedInvoice {
|
||||
const invoice = decodeBolt11Invoice(bolt11);
|
||||
return {
|
||||
bolt11: invoice.paymentRequest,
|
||||
paymentHash: invoice.paymentHash,
|
||||
amountMsat: invoice.millisatoshis,
|
||||
description: invoice.description,
|
||||
destination: invoice.destination,
|
||||
timestamp: invoice.timestamp,
|
||||
expiry: invoice.expirySeconds,
|
||||
cltvExpiry: invoice.cltvExpiry,
|
||||
routeHints: invoice.routes.length > 0,
|
||||
features: [...invoice.featureBits],
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam: the LRU is process-global, so a test that measures recovery cost needs to reset it. */
|
||||
export function clearBolt11Cache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Esplora REST client — the chain data source for the native on-chain wallet backend.
|
||||
//
|
||||
// Zeus's embedded backends (EmbeddedLND, LdkNode) get chain data from a node bound to a React Native
|
||||
// native module. Nothing about that ports to a Bun server process, so the server-side wallet reads the
|
||||
// chain over Esplora's HTTP API instead: mempool.space, blockstream.info, or a self-hosted esplora.
|
||||
// Every call is a plain unauthenticated GET, which is why the backend can serve watch-only requests
|
||||
// without ever touching key material.
|
||||
//
|
||||
// Two Esplora endpoints are NOT JSON and are handled explicitly below:
|
||||
// GET /tx/{txid}/hex → the raw transaction as a bare hex string
|
||||
// POST /tx → request body is bare hex, response body is the bare txid
|
||||
// Everything else is JSON, and every wire shape it returns is typed in this file.
|
||||
|
||||
import { BackendError, type BitcoinNetwork, type FeeEstimates } from './types';
|
||||
|
||||
// ── configuration ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EsploraConfig = {
|
||||
/** Base URL of the Esplora API root, e.g. `https://mempool.space/api`. Trailing slashes are trimmed. */
|
||||
baseUrl: string;
|
||||
network: BitcoinNetwork;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
// ── wire shapes ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Confirmation status shared by /tx, /address/{a}/txs and /address/{a}/utxo. */
|
||||
export type EsploraTxStatus = {
|
||||
confirmed: boolean;
|
||||
block_height?: number;
|
||||
block_hash?: string;
|
||||
/** Unix seconds of the containing block. Absent while unconfirmed. */
|
||||
block_time?: number;
|
||||
};
|
||||
|
||||
/** Aggregate funded/spent counters. Esplora reports one set for the chain and one for the mempool. */
|
||||
export type EsploraAddressStats = {
|
||||
funded_txo_count: number;
|
||||
funded_txo_sum: number;
|
||||
spent_txo_count: number;
|
||||
spent_txo_sum: number;
|
||||
tx_count: number;
|
||||
};
|
||||
|
||||
export type EsploraAddress = {
|
||||
address: string;
|
||||
chain_stats: EsploraAddressStats;
|
||||
mempool_stats: EsploraAddressStats;
|
||||
};
|
||||
|
||||
export type EsploraVout = {
|
||||
/** scriptPubKey, hex. */
|
||||
scriptpubkey: string;
|
||||
scriptpubkey_asm: string;
|
||||
/** Esplora's own classification: 'v0_p2wpkh' | 'v1_p2tr' | 'p2pkh' | 'p2sh' | 'op_return' | … */
|
||||
scriptpubkey_type: string;
|
||||
scriptpubkey_address?: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type EsploraVin = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
/** The output being spent. Null for coinbase inputs. */
|
||||
prevout: EsploraVout | null;
|
||||
scriptsig: string;
|
||||
scriptsig_asm: string;
|
||||
witness?: string[];
|
||||
is_coinbase: boolean;
|
||||
sequence: number;
|
||||
inner_redeemscript_asm?: string;
|
||||
inner_witnessscript_asm?: string;
|
||||
};
|
||||
|
||||
export type EsploraTx = {
|
||||
txid: string;
|
||||
version: number;
|
||||
locktime: number;
|
||||
vin: EsploraVin[];
|
||||
vout: EsploraVout[];
|
||||
size: number;
|
||||
weight: number;
|
||||
/** Absolute fee in sats. Zero for coinbase. */
|
||||
fee: number;
|
||||
status: EsploraTxStatus;
|
||||
};
|
||||
|
||||
export type EsploraUtxo = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
value: number;
|
||||
status: EsploraTxStatus;
|
||||
};
|
||||
|
||||
/** /fee-estimates: confirmation target (in blocks, as a string key) → sat/vB, as a float. */
|
||||
export type EsploraFeeEstimates = Record<string, number>;
|
||||
|
||||
// ── fee mapping ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Esplora publishes an estimate per confirmation target; FeeEstimates in types.ts is mempool.space's
|
||||
// five named tiers. These are the targets each tier maps onto.
|
||||
const FEE_TARGETS = {
|
||||
fastestFee: 1,
|
||||
halfHourFee: 3,
|
||||
hourFee: 6,
|
||||
economyFee: 144,
|
||||
minimumFee: 1008,
|
||||
} as const satisfies Record<keyof FeeEstimates, number>;
|
||||
|
||||
/** Nothing below the default minimum relay fee will propagate, so 1 sat/vB is the hard floor. */
|
||||
const MIN_SAT_VB = 1;
|
||||
|
||||
/**
|
||||
* Pick the estimate for a confirmation target. Esplora only publishes some targets (1..25, then 144,
|
||||
* 504, 1008), so when the exact key is missing we take the largest published target *at or below* the
|
||||
* one asked for — a shorter target always quotes a higher rate, so this errs towards confirming.
|
||||
*/
|
||||
function pickForTarget(raw: EsploraFeeEstimates, target: number): number | null {
|
||||
const exact = raw[String(target)];
|
||||
if (typeof exact === 'number' && Number.isFinite(exact)) return exact;
|
||||
|
||||
let best: number | null = null;
|
||||
let bestKey = -1;
|
||||
for (const [key, rate] of Object.entries(raw)) {
|
||||
const k = Number(key);
|
||||
if (!Number.isFinite(k) || !Number.isFinite(rate)) continue;
|
||||
if (k <= target && k > bestKey) {
|
||||
bestKey = k;
|
||||
best = rate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Esplora's target→rate map onto the five named tiers. Exported separately from the client so the
|
||||
* mapping can be unit-tested against a captured /fee-estimates payload with no network involved.
|
||||
*/
|
||||
export function mapFeeEstimates(raw: EsploraFeeEstimates): FeeEstimates {
|
||||
const at = (target: number): number => Math.max(MIN_SAT_VB, Math.ceil(pickForTarget(raw, target) ?? MIN_SAT_VB));
|
||||
|
||||
const fastestFee = at(FEE_TARGETS.fastestFee);
|
||||
// A tier must never quote more than a shorter-target tier: some esplora deployments serve a stale or
|
||||
// partial map where a longer target reads higher, and a UI that shows "1 hour" above "fastest" is a
|
||||
// bug report. Clamp each tier down to its faster neighbour.
|
||||
const halfHourFee = Math.min(fastestFee, at(FEE_TARGETS.halfHourFee));
|
||||
const hourFee = Math.min(halfHourFee, at(FEE_TARGETS.hourFee));
|
||||
const economyFee = Math.min(hourFee, at(FEE_TARGETS.economyFee));
|
||||
const minimumFee = Math.min(economyFee, at(FEE_TARGETS.minimumFee));
|
||||
|
||||
return { fastestFee, halfHourFee, hourFee, economyFee, minimumFee };
|
||||
}
|
||||
|
||||
// ── the client ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type RequestInitLite = {
|
||||
method?: 'GET' | 'POST';
|
||||
/** Sent verbatim as the body — Esplora's POST /tx takes bare hex, not JSON. */
|
||||
body?: string;
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export class EsploraChain {
|
||||
readonly network: BitcoinNetwork;
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(cfg: EsploraConfig) {
|
||||
this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
|
||||
this.network = cfg.network;
|
||||
this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/** Every request goes through here so timeouts and upstream errors become one BackendError shape. */
|
||||
private async text(path: string, init: RequestInitLite = {}): Promise<string> {
|
||||
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: init.method ?? 'GET',
|
||||
body: init.body,
|
||||
headers: init.contentType ? { 'Content-Type': init.contentType } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err);
|
||||
throw new BackendError(`esplora request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const body = await res.text();
|
||||
if (!res.ok) {
|
||||
// Esplora reports every failure as plain text: 'Transaction not found', 'sendrawtransaction RPC
|
||||
// error: {"code":-26,...}'. There is no JSON envelope to unwrap, so the text is the message.
|
||||
throw new BackendError(`esplora ${res.status} on ${path}: ${body.slice(0, 300)}`, res.status, 'ESPLORA_ERROR');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private async json<T>(path: string): Promise<T> {
|
||||
const body = await this.text(path);
|
||||
try {
|
||||
return JSON.parse(body) as T;
|
||||
} catch {
|
||||
throw new BackendError(`esplora returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
// ── endpoints ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /address/{addr} — funded/spent counters for the chain and the mempool. */
|
||||
getAddress(address: string): Promise<EsploraAddress> {
|
||||
return this.json<EsploraAddress>(`/address/${encodeURIComponent(address)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /address/{addr}/txs — the newest ~50 transactions (all mempool ones, then 25 confirmed).
|
||||
* Pass `afterTxid` to page further back through confirmed history via /txs/chain/{last_seen_txid}.
|
||||
*/
|
||||
getAddressTxs(address: string, afterTxid?: string): Promise<EsploraTx[]> {
|
||||
const addr = encodeURIComponent(address);
|
||||
const path = afterTxid ? `/address/${addr}/txs/chain/${encodeURIComponent(afterTxid)}` : `/address/${addr}/txs`;
|
||||
return this.json<EsploraTx[]>(path);
|
||||
}
|
||||
|
||||
/** GET /address/{addr}/utxo — unspent outputs, confirmed and unconfirmed. */
|
||||
getAddressUtxos(address: string): Promise<EsploraUtxo[]> {
|
||||
return this.json<EsploraUtxo[]>(`/address/${encodeURIComponent(address)}/utxo`);
|
||||
}
|
||||
|
||||
/** GET /tx/{txid}. */
|
||||
getTx(txid: string): Promise<EsploraTx> {
|
||||
return this.json<EsploraTx>(`/tx/${encodeURIComponent(txid)}`);
|
||||
}
|
||||
|
||||
/** GET /tx/{txid}/hex — plain text, not JSON. Needed as `nonWitnessUtxo` for legacy p2pkh inputs. */
|
||||
async getTxHex(txid: string): Promise<string> {
|
||||
const hex = (await this.text(`/tx/${encodeURIComponent(txid)}/hex`)).trim();
|
||||
if (!/^[0-9a-fA-F]+$/.test(hex)) {
|
||||
throw new BackendError(`esplora returned a non-hex body for tx ${txid}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/** GET /blocks/tip/height — plain integer in the body. */
|
||||
async getTipHeight(): Promise<number> {
|
||||
const body = (await this.text('/blocks/tip/height')).trim();
|
||||
const height = Number(body);
|
||||
if (!Number.isInteger(height) || height < 0) {
|
||||
throw new BackendError(`esplora returned a bad tip height: ${body.slice(0, 60)}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
/** GET /fee-estimates, folded onto the five named tiers. */
|
||||
async getFeeEstimates(): Promise<FeeEstimates> {
|
||||
return mapFeeEstimates(await this.json<EsploraFeeEstimates>('/fee-estimates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tx — the body is the raw transaction hex with no JSON wrapper, and the 200 response body is
|
||||
* the bare txid. A rejection comes back as a 400 with bitcoind's `sendrawtransaction RPC error` text,
|
||||
* which `text()` above surfaces verbatim; that message is the only diagnosis a caller gets.
|
||||
*/
|
||||
async broadcast(rawHex: string): Promise<string> {
|
||||
const txid = (await this.text('/tx', { method: 'POST', body: rawHex, contentType: 'text/plain' })).trim();
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(txid)) {
|
||||
throw new BackendError(`broadcast did not return a txid: ${txid.slice(0, 200)}`, 502, 'ESPLORA_ERROR');
|
||||
}
|
||||
return txid;
|
||||
}
|
||||
}
|
||||
|
||||
/** Alias kept for call sites that read better as a client than as a chain source. */
|
||||
export { EsploraChain as EsploraClient };
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { getConfig, hasStoreKey } from './upstream';
|
||||
import { lockAll } from './keys';
|
||||
import { invalidateAll } from './resolve';
|
||||
|
||||
// The officer-wallet sidecar. A bitcoin wallet in the shape Zeus models one — several interchangeable
|
||||
// backends behind one interface — but server-side, with the key material held here and nowhere else.
|
||||
//
|
||||
// WHY THIS PROCESS EXISTS SEPARATELY. Seeds and node credentials never enter the main Officer process.
|
||||
// The platform is a thin auth proxy (src/servers/api/wallet/router.ts) that forwards to this port and
|
||||
// holds nothing: no seed, no macaroon, no xpub. Compromising `officer` gets an attacker the ability to
|
||||
// *call* this sidecar as the authenticated owner — it does not get them a key, and it cannot spend from a
|
||||
// locked wallet, because a locked wallet has no key material in memory at all.
|
||||
//
|
||||
// BACKENDS (mirroring _references/zeus/backends/):
|
||||
// onchain — self-custodial. BIP39 seed sealed under an owner passphrase (keys.ts), BIP84/86/49
|
||||
// derivation, Esplora for chain data, bitcoinjs-lib for PSBT construction. This is the
|
||||
// server analogue of Zeus's EmbeddedLND/LdkNode, which are native-module-bound and cannot
|
||||
// be ported. Watch-only while locked; unlock only to sign.
|
||||
// lnd — LND REST + macaroon (ported from backends/LND.ts)
|
||||
// cln-rest — Core Lightning CLNRest + rune (ported from backends/CLNRest.ts)
|
||||
// lndhub — custodial LNDHub/BlueWallet REST (ported from backends/LndHub.ts)
|
||||
// nwc — Nostr Wallet Connect, NIP-47 (ported from backends/NostrWalletConnect.ts)
|
||||
//
|
||||
// When the owner runs their own node, it registers as an `lnd` or `cln-rest` wallet — no code change.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP CONTRACT — the platform strips its /api/wallet mount prefix before forwarding.
|
||||
//
|
||||
// GET /_health ours. Reports network, chain reachability, store key.
|
||||
// GET /_officer/config network + esplora + unlock TTL + storeKeyConfigured
|
||||
//
|
||||
// GET /_officer/wallets list. Never includes secrets.
|
||||
// POST /_officer/wallets create. Seeded wallets return the mnemonic ONCE,
|
||||
// and only when the sidecar generated it.
|
||||
// GET /_officer/wallets/active the currently selected wallet
|
||||
// GET /_officer/wallets/:id one wallet
|
||||
// PATCH /_officer/wallets/:id rename / defaultBip / config
|
||||
// DELETE /_officer/wallets/:id {passphrase} required when the wallet holds a seed
|
||||
// POST /_officer/wallets/:id/activate
|
||||
//
|
||||
// GET /_officer/wallets/:id/lock-state {hasSeed, unlocked, secondsRemaining}
|
||||
// POST /_officer/wallets/:id/unlock {passphrase, ttlSec?}
|
||||
// POST /_officer/wallets/:id/lock
|
||||
// POST /_officer/wallets/:id/passphrase {oldPassphrase, newPassphrase}
|
||||
// POST /_officer/wallets/:id/export-seed {passphrase} → the mnemonic. Logged as a warning.
|
||||
//
|
||||
// GET /_officer/wallets/:id/capabilities what this backend can actually do
|
||||
// GET /_officer/wallets/:id/info node/chain identity + sync state
|
||||
// GET /_officer/wallets/:id/balances on-chain confirmed/unconfirmed + lightning local/inbound
|
||||
// GET /_officer/wallets/:id/transactions?limit on-chain history, owner labels overlaid
|
||||
// GET /_officer/wallets/:id/address?peek fresh receive address
|
||||
// GET /_officer/wallets/:id/utxos coin control view, freeze flags + labels overlaid
|
||||
// POST /_officer/wallets/:id/utxos/freeze {outpoint, frozen, reason?}
|
||||
// GET /_officer/wallets/:id/fees sat/vB estimates
|
||||
// POST /_officer/wallets/:id/send on-chain spend. Requires an unlocked wallet.
|
||||
// GET /_officer/wallets/:id/invoices?limit
|
||||
// POST /_officer/wallets/:id/invoices create
|
||||
// GET /_officer/wallets/:id/invoices/:hash lookup
|
||||
// POST /_officer/wallets/:id/decode {bolt11}
|
||||
// GET /_officer/wallets/:id/payments?limit
|
||||
// POST /_officer/wallets/:id/pay {bolt11, amountMsat?, feeLimit…}
|
||||
// POST /_officer/wallets/:id/keysend {destination, amountMsat}
|
||||
// GET /_officer/wallets/:id/channels
|
||||
// GET /_officer/wallets/:id/peers
|
||||
// POST /_officer/wallets/:id/sign {message}
|
||||
// POST /_officer/wallets/:id/verify {message, signature}
|
||||
// GET|POST /_officer/wallets/:id/labels owner annotations for addresses and txids
|
||||
//
|
||||
// anything else 404
|
||||
//
|
||||
// Operations a backend cannot perform return 501 with code NOT_SUPPORTED, checked against its declared
|
||||
// capability set before dispatch — never a confusing upstream error. A locked wallet returns 423
|
||||
// WALLET_LOCKED from signing paths only; every read above keeps working.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const p = probeServer.port;
|
||||
probeServer.stop(true);
|
||||
if (p == null) throw new Error('failed to acquire a free port');
|
||||
return p;
|
||||
}
|
||||
|
||||
const port = getFreePort();
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
// Wallet payloads are small — PSBTs and invoices, never file uploads. A tight cap is free hardening.
|
||||
maxRequestBodySize: 1 * 1024 * 1024,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
const cfg = getConfig();
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${cfg.esploraUrl}/blocks/tip/height`, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
const height = res.ok ? Number(await res.text()) : null;
|
||||
return Response.json({
|
||||
ok: res.ok,
|
||||
network: cfg.network,
|
||||
esplora: cfg.esploraUrl,
|
||||
blockHeight: Number.isFinite(height) ? height : null,
|
||||
// Surfaced because wallet creation is refused without it, and that failure would otherwise
|
||||
// look like a bug rather than a missing config line.
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
ms: Date.now() - started,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
network: cfg.network,
|
||||
esplora: cfg.esploraUrl,
|
||||
error: String(err),
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
ms: Date.now() - started,
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
try {
|
||||
const res = await handleOfficerRoute(req, url);
|
||||
if (res) return res;
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
} catch (err) {
|
||||
// Method and path only. Bodies on this sidecar carry passphrases and mnemonics.
|
||||
console.error(`[wallet] ${req.method} ${url.pathname} failed`, err instanceof Error ? err.message : err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = getConfig();
|
||||
console.log(`[wallet] listening on 127.0.0.1:${port} — network=${cfg.network} esplora=${cfg.esploraUrl}`);
|
||||
if (!hasStoreKey()) {
|
||||
console.warn('[wallet] VAULT_STORE_KEY is unset — wallet creation will be refused until it is configured');
|
||||
}
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'wallet',
|
||||
capabilities: ['wallet'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
connection.send({ type: 'wallet:server', port });
|
||||
console.log(`[wallet] reported server port ${port} to API`);
|
||||
},
|
||||
});
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[wallet] ${signal} received, locking all wallets and shutting down...`);
|
||||
// Wipe key material before anything else. This is best-effort — see the caveat in keys.ts — but it
|
||||
// costs nothing and closes the obvious window on a graceful restart.
|
||||
lockAll();
|
||||
invalidateAll();
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
sealSeed,
|
||||
generateSeed,
|
||||
deriveAccountXpubs,
|
||||
UnlockSession,
|
||||
verifyPassphrase,
|
||||
exportMnemonic,
|
||||
changePassphrase,
|
||||
} from './keys';
|
||||
|
||||
/**
|
||||
* The seed custody core. This is the one file in the wallet where a bug is unrecoverable rather than
|
||||
* merely wrong — a mistake here either loses the owner's coins or leaks the key that spends them, so the
|
||||
* properties below are asserted rather than assumed.
|
||||
*
|
||||
* scrypt at N=2^17 makes each seal/open deliberately expensive (~1s), which is the entire point of the
|
||||
* parameter and also why this file carries a raised timeout instead of a smaller N.
|
||||
*/
|
||||
|
||||
// BIP39's canonical all-`abandon` vector, and the BIP32 root fingerprint it must produce.
|
||||
const VECTOR = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
const ROOT_FINGERPRINT = '73c5da0a';
|
||||
const PASS = 'correct horse battery staple';
|
||||
|
||||
const SLOW = 60_000;
|
||||
|
||||
describe('sealSeed / deriveAccountXpubs', () => {
|
||||
test(
|
||||
'seals the vector and derives all four accounts',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const { fingerprint, xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
||||
|
||||
expect(env.v).toBe(1);
|
||||
expect(fingerprint).toBe(ROOT_FINGERPRINT);
|
||||
for (const bip of [44, 49, 84, 86] as const) expect(xpubs[bip]).toBeTruthy();
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'the envelope never contains the plaintext mnemonic',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
expect(JSON.stringify(env)).not.toContain('abandon');
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'sealing is non-deterministic but derivation is stable',
|
||||
async () => {
|
||||
// Fresh salt and nonce per seal, so two seals of one seed must not be byte-identical — otherwise a
|
||||
// DB dump would reveal which wallets share a seed.
|
||||
const [a, b] = [await sealSeed(VECTOR, PASS), await sealSeed(VECTOR, PASS)];
|
||||
expect(JSON.stringify(a)).not.toBe(JSON.stringify(b));
|
||||
|
||||
const [xa, xb] = [await deriveAccountXpubs(a, PASS, 'bitcoin'), await deriveAccountXpubs(b, PASS, 'bitcoin')];
|
||||
expect(xb.xpubs[84]).toBe(xa.xpubs[84]);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'rejects a mnemonic that fails its checksum',
|
||||
async () => {
|
||||
const bad = VECTOR.replace(/about$/, 'abandon');
|
||||
await expect(sealSeed(bad, PASS)).rejects.toThrow(/BIP39/i);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'rejects a passphrase under the minimum length',
|
||||
async () => {
|
||||
await expect(sealSeed(VECTOR, 'short')).rejects.toThrow(/8 characters/);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'testnet derives a different account under the same root',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const [main, test_] = [
|
||||
await deriveAccountXpubs(env, PASS, 'bitcoin'),
|
||||
await deriveAccountXpubs(env, PASS, 'testnet'),
|
||||
];
|
||||
// Coin type 1' vs 0' — a different account, but the same seed, so the same root fingerprint.
|
||||
expect(test_.xpubs[84]).not.toBe(main.xpubs[84]);
|
||||
expect(test_.fingerprint).toBe(main.fingerprint);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe('passphrase handling', () => {
|
||||
test(
|
||||
'accepts the right passphrase and rejects a wrong one',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
expect(await verifyPassphrase(env, PASS)).toBe(true);
|
||||
expect(await verifyPassphrase(env, 'not the passphrase')).toBe(false);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'round-trips the mnemonic exactly',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
expect(await exportMnemonic(env, PASS)).toBe(VECTOR);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'rotation swaps the passphrase without disturbing the seed',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const next = 'an entirely different passphrase';
|
||||
const rotated = await changePassphrase(env, PASS, next);
|
||||
|
||||
expect(await verifyPassphrase(rotated, next)).toBe(true);
|
||||
expect(await verifyPassphrase(rotated, PASS)).toBe(false);
|
||||
expect(await exportMnemonic(rotated, next)).toBe(VECTOR);
|
||||
|
||||
// The xpubs are stored alongside the envelope; if rotation changed them the wallet would silently
|
||||
// start watching a different account and report a zero balance.
|
||||
const [before, after] = [
|
||||
await deriveAccountXpubs(env, PASS, 'bitcoin'),
|
||||
await deriveAccountXpubs(rotated, next, 'bitcoin'),
|
||||
];
|
||||
expect(after.xpubs[84]).toBe(before.xpubs[84]);
|
||||
expect(after.fingerprint).toBe(before.fingerprint);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe('UnlockSession', () => {
|
||||
test('holds no key material until unlocked', async () => {
|
||||
const s = new UnlockSession(1);
|
||||
expect(s.isUnlocked()).toBe(false);
|
||||
expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
|
||||
});
|
||||
|
||||
test(
|
||||
'unlocks, derives the stored xpub, then relocks',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const { xpubs } = await deriveAccountXpubs(env, PASS, 'bitcoin');
|
||||
|
||||
const s = new UnlockSession(1);
|
||||
await s.unlock(env, PASS, 60);
|
||||
expect(s.isUnlocked()).toBe(true);
|
||||
expect(s.secondsRemaining()).toBeGreaterThan(0);
|
||||
expect(s.secondsRemaining()).toBeLessThanOrEqual(60);
|
||||
|
||||
// Proves the unlocked root is the same key the watch-only xpub came from.
|
||||
expect(s.withRoot((r) => r.derive("m/84'/0'/0'").publicExtendedKey)).toBe(xpubs[84]);
|
||||
|
||||
s.lock();
|
||||
expect(s.isUnlocked()).toBe(false);
|
||||
expect(() => s.withRoot((r) => r.publicExtendedKey)).toThrow(/locked/i);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
test(
|
||||
'a failed unlock leaves the session locked',
|
||||
async () => {
|
||||
const env = await sealSeed(VECTOR, PASS);
|
||||
const s = new UnlockSession(1);
|
||||
await expect(s.unlock(env, 'the wrong passphrase', 60)).rejects.toThrow();
|
||||
expect(s.isUnlocked()).toBe(false);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe('generateSeed', () => {
|
||||
test(
|
||||
'produces a distinct, valid mnemonic that survives a round trip',
|
||||
async () => {
|
||||
const mnemonic = generateSeed();
|
||||
expect([12, 24]).toContain(mnemonic.split(' ').length);
|
||||
expect(generateSeed()).not.toBe(mnemonic);
|
||||
|
||||
const env = await sealSeed(mnemonic, 'a sufficiently long passphrase');
|
||||
expect(await exportMnemonic(env, 'a sufficiently long passphrase')).toBe(mnemonic);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb, timingSafeEqual } from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { HDKey } from '@scure/bip32';
|
||||
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
|
||||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||||
import type { BitcoinNetwork } from './types';
|
||||
import { BackendError, WalletLockedError } from './types';
|
||||
|
||||
// Seed custody for the wallet sidecar.
|
||||
//
|
||||
// THE THREAT MODEL, stated plainly, because it is the whole point of this file:
|
||||
//
|
||||
// Zeus stores its seed phrases as plaintext inside a JSON settings blob and leans entirely on the OS
|
||||
// keychain (storage/index.ts + stores/SettingsStore.ts:32-67 — `seedPhrase?: string[]`). A phone has a
|
||||
// secure enclave and a screen lock; a server has neither. So none of Zeus's key handling is reusable
|
||||
// here and this is written from scratch.
|
||||
//
|
||||
// The seed is protected by TWO independent secrets, and an attacker needs BOTH:
|
||||
//
|
||||
// 1. An owner passphrase, which is never persisted anywhere. It derives a KEK via scrypt and that
|
||||
// KEK wraps the random per-wallet DEK that actually encrypts the mnemonic.
|
||||
// 2. VAULT_STORE_KEY from the environment, applied by queries/wallet.ts (../../databases/officer_db)
|
||||
// over the already-encrypted envelope before it touches Postgres.
|
||||
//
|
||||
// Consequence: a stolen database dump is useless without .env, a stolen .env is useless without the
|
||||
// passphrase, and a full server compromise still cannot spend while the wallet is locked, because a
|
||||
// locked wallet holds no key material in memory at all.
|
||||
//
|
||||
// WATCH-ONLY WHILE LOCKED. The account xpubs are stored in the clear on purpose. Balances, history and
|
||||
// receive addresses therefore work with the wallet locked and the passphrase nowhere on the machine —
|
||||
// unlocking is required only to SIGN. This is the single most important property here: the wallet spends
|
||||
// almost all of its life locked and still fully readable.
|
||||
//
|
||||
// WHAT THIS CANNOT DO. Once unlocked, the root key is in the Bun process's heap and Node gives no way to
|
||||
// pin or reliably wipe it — GC may have copied it. `zeroize()` scrubs the buffers we own, which shrinks
|
||||
// the window but does not close it. That is why the unlock TTL is short and defaults tight.
|
||||
|
||||
const scrypt = promisify(scryptCb) as (
|
||||
password: string | Buffer,
|
||||
salt: Buffer,
|
||||
keylen: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number },
|
||||
) => Promise<Buffer>;
|
||||
|
||||
// N=2^17 / r=8 / p=1 → ~128 MiB and ~1s per attempt on this class of hardware. Deliberately painful:
|
||||
// this is the only thing standing between a leaked database + .env and the coins. Node's default maxmem
|
||||
// is 32 MiB, which these parameters blow through, so it must be raised explicitly or scrypt throws.
|
||||
const SCRYPT_N = 1 << 17;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
const SCRYPT_MAXMEM = 256 * 1024 * 1024;
|
||||
const KEY_LEN = 32;
|
||||
|
||||
/** Bump when the KDF parameters or envelope layout change, so old envelopes can be migrated on unlock. */
|
||||
const ENVELOPE_VERSION = 1;
|
||||
|
||||
export type SeedEnvelope = {
|
||||
v: number;
|
||||
/** base64, 16 bytes — scrypt salt for the KEK. */
|
||||
salt: string;
|
||||
/** base64(iv[12] | tag[16] | ciphertext) — the DEK, wrapped under the passphrase-derived KEK. */
|
||||
wrappedDek: string;
|
||||
/** base64(iv[12] | tag[16] | ciphertext) — the BIP39 mnemonic, encrypted under the DEK. */
|
||||
seed: string;
|
||||
/** Whether a BIP39 passphrase (the "25th word") is part of this seed. Affects derivation, not secrecy. */
|
||||
hasBip39Passphrase: boolean;
|
||||
};
|
||||
|
||||
// ── AES-256-GCM primitives ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function gcmEncrypt(key: Buffer, plaintext: Buffer): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
|
||||
}
|
||||
|
||||
function gcmDecrypt(key: Buffer, blob: string): Buffer {
|
||||
const buf = Buffer.from(blob, 'base64');
|
||||
if (buf.length < 29) throw new BackendError('malformed ciphertext', 500, 'BAD_ENVELOPE');
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, buf.subarray(0, 12));
|
||||
decipher.setAuthTag(buf.subarray(12, 28));
|
||||
return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]);
|
||||
}
|
||||
|
||||
/** Best-effort scrub of a buffer we own. See the caveat in the header comment. */
|
||||
function zeroize(buf: Buffer | null): void {
|
||||
if (buf) buf.fill(0);
|
||||
}
|
||||
|
||||
// ── envelope construction ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function deriveKek(passphrase: string, salt: Buffer): Promise<Buffer> {
|
||||
return scrypt(passphrase.normalize('NFKD'), salt, KEY_LEN, {
|
||||
N: SCRYPT_N,
|
||||
r: SCRYPT_R,
|
||||
p: SCRYPT_P,
|
||||
maxmem: SCRYPT_MAXMEM,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a mnemonic into a sealed envelope. The mnemonic is validated against the BIP39 wordlist first —
|
||||
* importing a typo'd phrase silently produces a valid-but-wrong wallet that shows a zero balance, which
|
||||
* is a genuinely awful failure mode to debug.
|
||||
*/
|
||||
export async function sealSeed(
|
||||
mnemonic: string,
|
||||
ownerPassphrase: string,
|
||||
bip39Passphrase?: string,
|
||||
): Promise<SeedEnvelope> {
|
||||
const normalized = mnemonic.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
if (!validateMnemonic(normalized, wordlist)) {
|
||||
throw new BackendError('not a valid BIP39 mnemonic (checksum or wordlist mismatch)', 400, 'BAD_MNEMONIC');
|
||||
}
|
||||
if (ownerPassphrase.length < 8) {
|
||||
throw new BackendError('unlock passphrase must be at least 8 characters', 400, 'WEAK_PASSPHRASE');
|
||||
}
|
||||
|
||||
const salt = randomBytes(16);
|
||||
const kek = await deriveKek(ownerPassphrase, salt);
|
||||
const dek = randomBytes(KEY_LEN);
|
||||
// The BIP39 passphrase lives inside the encrypted payload, not beside it: it is as sensitive as the
|
||||
// words themselves, since together they are the wallet.
|
||||
const payload = Buffer.from(JSON.stringify({ mnemonic: normalized, bip39Passphrase: bip39Passphrase ?? '' }), 'utf8');
|
||||
|
||||
try {
|
||||
return {
|
||||
v: ENVELOPE_VERSION,
|
||||
salt: salt.toString('base64'),
|
||||
wrappedDek: gcmEncrypt(kek, dek),
|
||||
seed: gcmEncrypt(dek, payload),
|
||||
hasBip39Passphrase: Boolean(bip39Passphrase),
|
||||
};
|
||||
} finally {
|
||||
zeroize(kek);
|
||||
zeroize(dek);
|
||||
zeroize(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a fresh 12- or 24-word mnemonic. 24 words (256 bits) is the default. */
|
||||
export function generateSeed(words: 12 | 24 = 24): string {
|
||||
return generateMnemonic(wordlist, words === 12 ? 128 : 256);
|
||||
}
|
||||
|
||||
type OpenedSeed = { mnemonic: string; bip39Passphrase: string };
|
||||
|
||||
async function openEnvelope(env: SeedEnvelope, ownerPassphrase: string): Promise<OpenedSeed> {
|
||||
if (env.v !== ENVELOPE_VERSION) {
|
||||
throw new BackendError(`unsupported seed envelope version ${env.v}`, 500, 'BAD_ENVELOPE');
|
||||
}
|
||||
const kek = await deriveKek(ownerPassphrase, Buffer.from(env.salt, 'base64'));
|
||||
let dek: Buffer | null = null;
|
||||
try {
|
||||
// A wrong passphrase fails here, as a GCM tag mismatch. That is the ONLY signal — we never store a
|
||||
// verifier hash of the passphrase, because a verifier is an offline-crackable oracle.
|
||||
dek = gcmDecrypt(kek, env.wrappedDek);
|
||||
const payload = gcmDecrypt(dek, env.seed);
|
||||
try {
|
||||
return JSON.parse(payload.toString('utf8')) as OpenedSeed;
|
||||
} finally {
|
||||
zeroize(payload);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError) throw err;
|
||||
throw new BackendError('incorrect passphrase', 401, 'BAD_PASSPHRASE');
|
||||
} finally {
|
||||
zeroize(kek);
|
||||
zeroize(dek);
|
||||
}
|
||||
}
|
||||
|
||||
// ── derivation ───────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Bip = 44 | 49 | 84 | 86;
|
||||
|
||||
/** Mainnet is coin type 0; every test network shares coin type 1 (BIP44). */
|
||||
export function coinType(network: BitcoinNetwork): 0 | 1 {
|
||||
return network === 'bitcoin' ? 0 : 1;
|
||||
}
|
||||
|
||||
export function accountPath(bip: Bip, network: BitcoinNetwork, account = 0): string {
|
||||
return `m/${bip}'/${coinType(network)}'/${account}'`;
|
||||
}
|
||||
|
||||
function rootFromSeed(opened: OpenedSeed): HDKey {
|
||||
const seed = Buffer.from(mnemonicToSeedSync(opened.mnemonic, opened.bip39Passphrase || undefined));
|
||||
try {
|
||||
return HDKey.fromMasterSeed(seed);
|
||||
} finally {
|
||||
zeroize(seed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the public account descriptors WITHOUT retaining any private material. Called once at import
|
||||
* time; the returned xpubs are stored in the clear and are what makes watch-only-while-locked work.
|
||||
*/
|
||||
export async function deriveAccountXpubs(
|
||||
env: SeedEnvelope,
|
||||
ownerPassphrase: string,
|
||||
network: BitcoinNetwork,
|
||||
): Promise<{ fingerprint: string; xpubs: Record<Bip, string> }> {
|
||||
const opened = await openEnvelope(env, ownerPassphrase);
|
||||
const root = rootFromSeed(opened);
|
||||
try {
|
||||
const fingerprint = Buffer.from(new Uint8Array(new Uint32Array([root.fingerprint]).buffer))
|
||||
.reverse()
|
||||
.toString('hex');
|
||||
const xpubs = {} as Record<Bip, string>;
|
||||
for (const bip of [44, 49, 84, 86] as const) {
|
||||
const node = root.derive(accountPath(bip, network));
|
||||
xpubs[bip] = node.publicExtendedKey;
|
||||
}
|
||||
return { fingerprint, xpubs };
|
||||
} finally {
|
||||
root.wipePrivateData();
|
||||
}
|
||||
}
|
||||
|
||||
// ── the unlock session ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TTL_SEC = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900);
|
||||
|
||||
// Brute-force resistance. scrypt already makes each guess cost ~1s and ~128 MiB, but an attacker with
|
||||
// the DB and .env can grind offline anyway — this only protects the live endpoint. Backoff is per
|
||||
// wallet id and resets on success.
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const LOCKOUT_MS = 60_000;
|
||||
|
||||
type Attempts = { count: number; lockedUntil: number };
|
||||
const attempts = new Map<number, Attempts>();
|
||||
|
||||
function checkLockout(walletId: number): void {
|
||||
const a = attempts.get(walletId);
|
||||
if (a && a.lockedUntil > Date.now()) {
|
||||
const secs = Math.ceil((a.lockedUntil - Date.now()) / 1000);
|
||||
throw new BackendError(`too many failed attempts, retry in ${secs}s`, 429, 'LOCKED_OUT');
|
||||
}
|
||||
}
|
||||
|
||||
function recordFailure(walletId: number): void {
|
||||
const a = attempts.get(walletId) ?? { count: 0, lockedUntil: 0 };
|
||||
a.count += 1;
|
||||
if (a.count >= MAX_ATTEMPTS) {
|
||||
a.lockedUntil = Date.now() + LOCKOUT_MS;
|
||||
a.count = 0;
|
||||
}
|
||||
attempts.set(walletId, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* A live, unlocked wallet. Holds the derived root key in memory and nothing else — the mnemonic itself
|
||||
* is decrypted, converted to a root key, and dropped inside `unlock()`; it is never retained.
|
||||
*
|
||||
* Structurally satisfies the `Signer` interface that backends/onchain.ts consumes.
|
||||
*/
|
||||
export class UnlockSession {
|
||||
private root: HDKey | null = null;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private expiresAt = 0;
|
||||
|
||||
constructor(readonly walletId: number) {}
|
||||
|
||||
isUnlocked(): boolean {
|
||||
return this.root !== null && Date.now() < this.expiresAt;
|
||||
}
|
||||
|
||||
/** Seconds until auto-lock, or 0 when locked. For the UI's countdown. */
|
||||
secondsRemaining(): number {
|
||||
if (!this.isUnlocked()) return 0;
|
||||
return Math.max(0, Math.ceil((this.expiresAt - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
async unlock(env: SeedEnvelope, ownerPassphrase: string, ttlSec = DEFAULT_TTL_SEC): Promise<void> {
|
||||
checkLockout(this.walletId);
|
||||
let opened: OpenedSeed;
|
||||
try {
|
||||
opened = await openEnvelope(env, ownerPassphrase);
|
||||
} catch (err) {
|
||||
recordFailure(this.walletId);
|
||||
throw err;
|
||||
}
|
||||
attempts.delete(this.walletId);
|
||||
|
||||
this.lock(); // replace any existing session rather than leaking the old root
|
||||
this.root = rootFromSeed(opened);
|
||||
// Drop the words immediately — the root key is all any signing operation needs.
|
||||
opened.mnemonic = '';
|
||||
opened.bip39Passphrase = '';
|
||||
this.arm(ttlSec);
|
||||
}
|
||||
|
||||
private arm(ttlSec: number): void {
|
||||
this.expiresAt = Date.now() + ttlSec * 1000;
|
||||
this.timer = setTimeout(() => this.lock(), ttlSec * 1000);
|
||||
// Don't hold the event loop open just to auto-lock; shutdown wipes memory anyway.
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
lock(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.root?.wipePrivateData();
|
||||
this.root = null;
|
||||
this.expiresAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with the root key. The ONLY way key material leaves this class, and it never escapes as a
|
||||
* return value by construction — callers get a derived signature, not the key.
|
||||
*
|
||||
* Deliberately does NOT slide the TTL. An unlock is a bounded window the owner opened on purpose;
|
||||
* refreshing it on use would let a compromised session stay open indefinitely by signing.
|
||||
*/
|
||||
withRoot<T>(fn: (root: HDKey) => T): T {
|
||||
if (!this.isUnlocked() || !this.root) {
|
||||
this.lock();
|
||||
throw new WalletLockedError();
|
||||
}
|
||||
return fn(this.root);
|
||||
}
|
||||
}
|
||||
|
||||
// One session per wallet id, process-wide. The sidecar is a single process, so this map IS the unlock
|
||||
// state — there is no cross-process sharing and deliberately no persistence: a sidecar restart relocks
|
||||
// every wallet, which is the correct default.
|
||||
const sessions = new Map<number, UnlockSession>();
|
||||
|
||||
export function sessionFor(walletId: number): UnlockSession {
|
||||
let s = sessions.get(walletId);
|
||||
if (!s) {
|
||||
s = new UnlockSession(walletId);
|
||||
sessions.set(walletId, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function lockAll(): void {
|
||||
for (const s of sessions.values()) s.lock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a passphrase without opening a session — used before destructive operations (seed export,
|
||||
* wallet deletion) so they need a fresh confirmation even when the wallet is already unlocked.
|
||||
*/
|
||||
export async function verifyPassphrase(env: SeedEnvelope, ownerPassphrase: string): Promise<boolean> {
|
||||
try {
|
||||
await openEnvelope(env, ownerPassphrase);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the mnemonic. The only function that returns raw seed words, and it exists solely so the owner
|
||||
* can back up or migrate. Always requires the passphrase even if a session is open, and callers must
|
||||
* gate it behind a fresh confirmation.
|
||||
*/
|
||||
export async function exportMnemonic(env: SeedEnvelope, ownerPassphrase: string): Promise<string> {
|
||||
const opened = await openEnvelope(env, ownerPassphrase);
|
||||
return opened.mnemonic;
|
||||
}
|
||||
|
||||
/** Re-wrap an existing seed under a new passphrase. Requires the old one; never touches the DEK. */
|
||||
export async function changePassphrase(
|
||||
env: SeedEnvelope,
|
||||
oldPassphrase: string,
|
||||
newPassphrase: string,
|
||||
): Promise<SeedEnvelope> {
|
||||
const opened = await openEnvelope(env, oldPassphrase);
|
||||
return sealSeed(opened.mnemonic, newPassphrase, opened.bip39Passphrase || undefined);
|
||||
}
|
||||
|
||||
/** Constant-time compare for any confirmation token we hand out and take back. */
|
||||
export function safeEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
// Coin selection and PSBT construction/signing for the native on-chain wallet backend.
|
||||
//
|
||||
// This is the raw-transaction half of the wallet: given a set of UTXOs discovered from the chain (see
|
||||
// chain.ts) and a BIP32 root supplied by the caller, it picks inputs, builds a bitcoinjs-lib Psbt,
|
||||
// signs it, and hands back a finalised transaction ready for `EsploraClient.broadcast`.
|
||||
//
|
||||
// It is modelled on Zeus's SweepStore (stores/SweepStore.ts) — the same p2pkh / p2sh-p2wpkh / p2wpkh /
|
||||
// p2tr input construction, the same `toXOnly` from bitcoinjs-lib/src/psbt/bip371 — with three
|
||||
// differences that matter:
|
||||
//
|
||||
// 1. Zeus sweeps a single WIF key and estimates the fee by signing a throwaway PSBT. Here the fee is
|
||||
// estimated analytically from per-script-type weights (see WEIGHTS below), because selection has
|
||||
// to know the fee before it knows the input set, and signing to find out costs a key.
|
||||
// 2. Zeus refuses p2tr sweeps (ZEUS-3276). Taproot key-path spends work here: the private key is
|
||||
// BIP341-tweaked before signing and the signer exposes `signSchnorr`.
|
||||
// 3. SECURITY: no function in this file reads a seed from disk, env or module state. `signAndFinalize`
|
||||
// takes the root HDKey as a parameter and the caller — keys.ts, via the backend's signer interface
|
||||
// — decides whether it is willing to hand one over. Broadcast deliberately lives in chain.ts so
|
||||
// the root is never held across an `await`.
|
||||
|
||||
import type { 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 { BackendError, type AddressType, type BitcoinNetwork } from './types';
|
||||
|
||||
// bitcoinjs-lib needs an ECC backend for anything taproot (x-only point tweaking). Module-level and
|
||||
// idempotent — `initEcc()` is exported so a consumer that only touches address derivation can force it
|
||||
// without depending on import order.
|
||||
let eccReady = false;
|
||||
|
||||
export function initEcc(): void {
|
||||
if (eccReady) return;
|
||||
bitcoin.initEccLib(ecc);
|
||||
eccReady = true;
|
||||
}
|
||||
|
||||
initEcc();
|
||||
|
||||
// ── networks ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* bitcoinjs-lib ships bitcoin/testnet/regtest only. Signet shares testnet's address parameters exactly
|
||||
* (`tb` bech32 HRP, 0x6f p2pkh version, 0xc4 p2sh version) — only the genesis block and message magic
|
||||
* differ, and neither is used for address encoding or signing — so signet maps onto testnet.
|
||||
*/
|
||||
export function networkFor(network: BitcoinNetwork): bitcoin.Network {
|
||||
switch (network) {
|
||||
case 'bitcoin':
|
||||
return bitcoin.networks.bitcoin;
|
||||
case 'regtest':
|
||||
return bitcoin.networks.regtest;
|
||||
case 'testnet':
|
||||
case 'signet':
|
||||
return bitcoin.networks.testnet;
|
||||
}
|
||||
}
|
||||
|
||||
/** BIP44 coin type: 0 for mainnet, 1 for every test chain (SLIP-44 "Testnet (all coins)"). */
|
||||
export function coinTypeFor(network: BitcoinNetwork): 0 | 1 {
|
||||
return network === 'bitcoin' ? 0 : 1;
|
||||
}
|
||||
|
||||
// ── script classification ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Classify a scriptPubKey. Returns null for anything the wallet cannot own or size precisely (p2wsh,
|
||||
* bare multisig, op_return, future witness versions).
|
||||
*
|
||||
* Note that a bare p2sh script is reported as 'p2sh-p2wpkh'. On the *output* side that is exact — every
|
||||
* p2sh output is 23 bytes regardless of what redeems it — and on the input side the wallet only ever
|
||||
* owns the wrapped-segwit form, so the conflation is safe in both directions.
|
||||
*/
|
||||
export function scriptType(script: Uint8Array): AddressType | null {
|
||||
const b = script;
|
||||
if (b.length === 25 && b[0] === 0x76 && b[1] === 0xa9 && b[2] === 0x14 && b[23] === 0x88 && b[24] === 0xac) {
|
||||
return 'p2pkh';
|
||||
}
|
||||
if (b.length === 23 && b[0] === 0xa9 && b[1] === 0x14 && b[22] === 0x87) return 'p2sh-p2wpkh';
|
||||
if (b.length === 22 && b[0] === 0x00 && b[1] === 0x14) return 'p2wpkh';
|
||||
if (b.length === 34 && b[0] === 0x51 && b[1] === 0x20) return 'p2tr';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Same, from hex. */
|
||||
export function scriptTypeFromHex(hex: string): AddressType | null {
|
||||
return scriptType(Buffer.from(hex, 'hex'));
|
||||
}
|
||||
|
||||
/** Classify a destination address by the script it encodes. Throws when the address is not valid here. */
|
||||
export function addressScriptType(address: string, network: bitcoin.Network): AddressType | null {
|
||||
return scriptType(outputScriptFor(address, network));
|
||||
}
|
||||
|
||||
/** `bitcoin.address.toOutputScript` with the failure turned into a 400 instead of a bare Error. */
|
||||
export function outputScriptFor(address: string, network: bitcoin.Network): Buffer {
|
||||
try {
|
||||
return bitcoin.address.toOutputScript(address, network);
|
||||
} catch {
|
||||
throw new BackendError(`invalid address for this network: ${address}`, 400, 'INVALID_ADDRESS');
|
||||
}
|
||||
}
|
||||
|
||||
// ── size and dust constants ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Everything here is in *weight units* (4 wu = 1 vbyte) so segwit's quarter-vbyte discount survives the
|
||||
// arithmetic instead of being rounded away per input. A flat 148-in/34-out estimate — the usual
|
||||
// shortcut — overpays a p2wpkh spend by ~2.2x and a p2tr spend by ~2.6x, which at any real fee rate is
|
||||
// money handed to a miner for nothing.
|
||||
//
|
||||
// Input weights, assuming a 72-byte low-R DER signature (71 is common, 72 is the worst case) and a
|
||||
// 33-byte compressed pubkey:
|
||||
//
|
||||
// p2pkh 32 txid + 4 vout + 1 len + 107 scriptSig + 4 seq = 148 vB → 592 wu
|
||||
// p2sh-p2wpkh 64 base vB (scriptSig = 23-byte redeemScript push) + 108 wu witness → 364 wu
|
||||
// p2wpkh 41 base vB + 108 wu witness (2 items: 72-byte sig, 33-byte key) → 272 wu
|
||||
// p2tr key-path 41 base vB + 66 wu witness (1 item: 64-byte schnorr sig) → 230 wu
|
||||
//
|
||||
// which come out at 148 / 91 / 68 / 57.5 vbytes — the same numbers Bitcoin Core assumes.
|
||||
const INPUT_WEIGHT: Record<AddressType, number> = {
|
||||
p2pkh: 592,
|
||||
'p2sh-p2wpkh': 364,
|
||||
p2wpkh: 272,
|
||||
p2tr: 230,
|
||||
};
|
||||
|
||||
// Output weights: 8-byte value + 1-byte script length + the script itself, all ×4.
|
||||
// p2pkh 25B script → 34 vB, p2sh 23B → 32 vB, p2wpkh 22B → 31 vB, p2tr 34B → 43 vB.
|
||||
const OUTPUT_WEIGHT: Record<AddressType, number> = {
|
||||
p2pkh: 136,
|
||||
'p2sh-p2wpkh': 128,
|
||||
p2wpkh: 124,
|
||||
p2tr: 172,
|
||||
};
|
||||
|
||||
/** Unclassifiable output (p2wsh, future witness versions): charge the 43-vbyte p2tr/p2wsh size. */
|
||||
const OUTPUT_WEIGHT_UNKNOWN = 172;
|
||||
|
||||
/** version(4) + locktime(4), ×4. The input/output count varints are added separately. */
|
||||
const TX_OVERHEAD_WEIGHT = 32;
|
||||
|
||||
/** Segwit marker + flag: 1 byte each, but they live in the witness so they weigh 1 wu each. */
|
||||
const SEGWIT_MARKER_WEIGHT = 2;
|
||||
|
||||
/**
|
||||
* Bitcoin Core's dust threshold: 3 sat/vB (the default dustRelayFee) times the size of the output plus
|
||||
* the size of the input that would eventually spend it — witness inputs are counted as a flat 67 vB.
|
||||
* p2pkh (34+148)*3 = 546 · p2sh (32+148)*3 = 540 · p2wpkh (31+67)*3 = 294 · p2tr (43+67)*3 = 330
|
||||
* A change output below its threshold is not created; the remainder is donated to the fee instead.
|
||||
*/
|
||||
export const DUST_THRESHOLD: Record<AddressType, number> = {
|
||||
p2pkh: 546,
|
||||
'p2sh-p2wpkh': 540,
|
||||
p2wpkh: 294,
|
||||
p2tr: 330,
|
||||
};
|
||||
|
||||
/** Dust floor for an output we could not classify — use the most demanding value we know. */
|
||||
const DUST_UNKNOWN = 546;
|
||||
|
||||
/** The network will not relay below this, so a caller asking for less gets bumped rather than stuck. */
|
||||
const MIN_SAT_VB = 1;
|
||||
|
||||
function varintWeight(n: number): number {
|
||||
if (n < 0xfd) return 4;
|
||||
if (n <= 0xffff) return 12;
|
||||
return 20;
|
||||
}
|
||||
|
||||
function isSegwit(type: AddressType): boolean {
|
||||
return type !== 'p2pkh';
|
||||
}
|
||||
|
||||
export type VsizeParams = {
|
||||
inputs: AddressType[];
|
||||
/** Output script types, in order. `null` means "unclassifiable" and is charged 43 vbytes. */
|
||||
outputs: (AddressType | null)[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Virtual size of the transaction these inputs and outputs would produce, rounded up. Accurate to
|
||||
* within one vbyte per input (the DER signature is occasionally 71 bytes rather than 72), always in the
|
||||
* conservative direction, so the realised fee rate lands at or just above what was asked for.
|
||||
*/
|
||||
export function estimateVsize({ inputs, outputs }: VsizeParams): number {
|
||||
let weight = TX_OVERHEAD_WEIGHT + varintWeight(inputs.length) + varintWeight(outputs.length);
|
||||
if (inputs.some(isSegwit)) weight += SEGWIT_MARKER_WEIGHT;
|
||||
for (const type of inputs) weight += INPUT_WEIGHT[type];
|
||||
for (const type of outputs) weight += type === null ? OUTPUT_WEIGHT_UNKNOWN : OUTPUT_WEIGHT[type];
|
||||
return Math.ceil(weight / 4);
|
||||
}
|
||||
|
||||
// ── coin selection ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A UTXO the selector may spend. `derivationPath` is the FULL path from the wallet root
|
||||
* (`m/84'/0'/0'/0/7`), not the account-relative form the public Utxo type carries, because it is used
|
||||
* verbatim to derive the signing key.
|
||||
*/
|
||||
export type SpendableUtxo = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
amountSats: number;
|
||||
address: string;
|
||||
addressType: AddressType;
|
||||
confirmations: number;
|
||||
derivationPath: string;
|
||||
frozen: boolean;
|
||||
/** scriptPubKey of the output being spent, hex. */
|
||||
scriptPubKeyHex: string;
|
||||
/** Compressed 33-byte pubkey of the owning address, hex. */
|
||||
pubkeyHex: string;
|
||||
};
|
||||
|
||||
export type CoinSelectionParams = {
|
||||
utxos: SpendableUtxo[];
|
||||
/** Sats to pay the recipient. Ignored when `sendAll` is set. */
|
||||
targetSats: number;
|
||||
sendAll?: boolean;
|
||||
satPerVbyte: number;
|
||||
/** Script type of the recipient output; null when it is a script we cannot size exactly. */
|
||||
recipientType: AddressType | null;
|
||||
/** Script type the change output would use. */
|
||||
changeType: AddressType;
|
||||
/** Coin control: restrict the input set to these `txid:vout` outpoints. */
|
||||
outpoints?: string[];
|
||||
spendUnconfirmed?: boolean;
|
||||
};
|
||||
|
||||
export type CoinSelection = {
|
||||
inputs: SpendableUtxo[];
|
||||
/** Sats actually paid to the recipient. Equals `targetSats` unless `sendAll`. */
|
||||
outputSats: number;
|
||||
/** Sats returned to the wallet, or null when no change output is created. */
|
||||
changeSats: number | null;
|
||||
feeSats: number;
|
||||
/** The vsize the fee was computed from. */
|
||||
vsize: number;
|
||||
};
|
||||
|
||||
const sumSats = (utxos: SpendableUtxo[]): number => utxos.reduce((acc, u) => acc + u.amountSats, 0);
|
||||
|
||||
const outpointOf = (u: SpendableUtxo): string => `${u.txid}:${u.vout}`;
|
||||
|
||||
function dustFor(type: AddressType | null): number {
|
||||
return type === null ? DUST_UNKNOWN : DUST_THRESHOLD[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Coin selection: a two-phase accumulative selector. Deliberately not branch-and-bound — BnB's payoff
|
||||
* is finding changeless solutions in a large UTXO set, and it needs a waste metric plus a fallback
|
||||
* anyway. Instead:
|
||||
*
|
||||
* Phase 1 — smallest sufficient single input. Walk the eligible UTXOs smallest-first and take the
|
||||
* first one that can cover the payment on its own. One input is the cheapest possible
|
||||
* transaction, and going smallest-first quietly consolidates the wallet's dust over time.
|
||||
* Phase 2 — largest-first accumulation. Nothing single-handedly covers it, so add UTXOs
|
||||
* largest-first (confirmed before unconfirmed) until the total covers payment + fee. Going
|
||||
* largest-first minimises the input count, and each input costs real money.
|
||||
*
|
||||
* Both phases decide change the same way: prefer a change output, but if what is left after the
|
||||
* with-change fee falls under the dust threshold, drop the change output and donate the remainder to
|
||||
* the fee. The donation is bounded by roughly (one output's fee + one dust threshold), so it can never
|
||||
* quietly become a large overpayment.
|
||||
*/
|
||||
export function selectCoins(params: CoinSelectionParams): CoinSelection {
|
||||
const rate = Math.max(params.satPerVbyte, MIN_SAT_VB);
|
||||
const allow = params.outpoints && params.outpoints.length > 0 ? new Set(params.outpoints) : null;
|
||||
|
||||
const eligible = params.utxos.filter((u) => {
|
||||
// An explicit coin-control pick is authoritative: it overrides both the frozen flag and the
|
||||
// confirmed-only default, because the user named this exact outpoint.
|
||||
if (allow) return allow.has(outpointOf(u));
|
||||
if (u.frozen) return false;
|
||||
if (!params.spendUnconfirmed && u.confirmations < 1) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (eligible.length === 0) {
|
||||
const why = allow ? 'none of the selected outpoints are spendable' : 'no spendable UTXOs';
|
||||
throw new BackendError(why, 400, 'INSUFFICIENT_FUNDS');
|
||||
}
|
||||
|
||||
if (params.sendAll) return sweepAll(eligible, rate, params.recipientType);
|
||||
|
||||
if (!Number.isInteger(params.targetSats) || params.targetSats <= 0) {
|
||||
throw new BackendError('amountSats must be a positive integer', 400, 'INVALID_AMOUNT');
|
||||
}
|
||||
|
||||
const attempt = (inputs: SpendableUtxo[]): CoinSelection | null => {
|
||||
const total = sumSats(inputs);
|
||||
const types = inputs.map((u) => u.addressType);
|
||||
const withChange = estimateVsize({ inputs: types, outputs: [params.recipientType, params.changeType] });
|
||||
const noChange = estimateVsize({ inputs: types, outputs: [params.recipientType] });
|
||||
const feeWithChange = Math.ceil(withChange * rate);
|
||||
const feeNoChange = Math.ceil(noChange * rate);
|
||||
|
||||
const change = total - params.targetSats - feeWithChange;
|
||||
if (change >= DUST_THRESHOLD[params.changeType]) {
|
||||
return {
|
||||
inputs: [...inputs],
|
||||
outputSats: params.targetSats,
|
||||
changeSats: change,
|
||||
feeSats: feeWithChange,
|
||||
vsize: withChange,
|
||||
};
|
||||
}
|
||||
if (total >= params.targetSats + feeNoChange) {
|
||||
// Changeless: everything above the payment is fee. Smaller transaction, no dust output created.
|
||||
return {
|
||||
inputs: [...inputs],
|
||||
outputSats: params.targetSats,
|
||||
changeSats: null,
|
||||
feeSats: total - params.targetSats,
|
||||
vsize: noChange,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Phase 1 — smallest sufficient single input.
|
||||
const ascending = [...eligible].sort((a, b) => a.amountSats - b.amountSats);
|
||||
for (const utxo of ascending) {
|
||||
const single = attempt([utxo]);
|
||||
if (single) return single;
|
||||
}
|
||||
|
||||
// Phase 2 — largest-first accumulation, confirmed coins ahead of unconfirmed ones.
|
||||
const ordered = [...eligible].sort((a, b) => {
|
||||
const aPending = a.confirmations > 0 ? 0 : 1;
|
||||
const bPending = b.confirmations > 0 ? 0 : 1;
|
||||
if (aPending !== bPending) return aPending - bPending;
|
||||
return b.amountSats - a.amountSats;
|
||||
});
|
||||
|
||||
const chosen: SpendableUtxo[] = [];
|
||||
for (const utxo of ordered) {
|
||||
chosen.push(utxo);
|
||||
const selection = attempt(chosen);
|
||||
if (selection) return selection;
|
||||
}
|
||||
|
||||
const available = sumSats(eligible);
|
||||
throw new BackendError(
|
||||
`insufficient funds: ${available} sat available, ${params.targetSats} sat requested plus fees`,
|
||||
400,
|
||||
'INSUFFICIENT_FUNDS',
|
||||
);
|
||||
}
|
||||
|
||||
/** sendAll: every eligible coin in, one output out, the fee taken off that output. */
|
||||
function sweepAll(inputs: SpendableUtxo[], rate: number, recipientType: AddressType | null): CoinSelection {
|
||||
const total = sumSats(inputs);
|
||||
const vsize = estimateVsize({ inputs: inputs.map((u) => u.addressType), outputs: [recipientType] });
|
||||
const feeSats = Math.ceil(vsize * rate);
|
||||
const outputSats = total - feeSats;
|
||||
if (outputSats < dustFor(recipientType)) {
|
||||
throw new BackendError(
|
||||
`sweep leaves ${outputSats} sat after a ${feeSats} sat fee, below the dust threshold`,
|
||||
400,
|
||||
'INSUFFICIENT_FUNDS',
|
||||
);
|
||||
}
|
||||
return { inputs: [...inputs], outputSats, changeSats: null, feeSats, vsize };
|
||||
}
|
||||
|
||||
// ── PSBT construction ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** RBF-signalling sequence (BIP125): any input below 0xfffffffe marks the whole transaction replaceable. */
|
||||
const SEQUENCE_RBF = 0xfffffffd;
|
||||
const SEQUENCE_FINAL = 0xffffffff;
|
||||
|
||||
export type PsbtInputSource = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
amountSats: number;
|
||||
addressType: AddressType;
|
||||
/** scriptPubKey being spent, hex. */
|
||||
scriptPubKeyHex: string;
|
||||
/** Compressed 33-byte pubkey of the owning address, hex. */
|
||||
pubkeyHex: string;
|
||||
/** Full BIP32 path from the wallet root — returned in `inputPaths` for `signAndFinalize`. */
|
||||
derivationPath: string;
|
||||
/** Whole previous transaction, hex. Required for p2pkh inputs, unused otherwise. */
|
||||
prevTxHex?: string;
|
||||
};
|
||||
|
||||
export type PsbtOutputSpec = { address: string; amountSats: number };
|
||||
|
||||
export type BuildPsbtParams = {
|
||||
network: bitcoin.Network;
|
||||
inputs: PsbtInputSource[];
|
||||
outputs: PsbtOutputSpec[];
|
||||
/** Signal RBF. Default true. */
|
||||
rbf?: boolean;
|
||||
locktime?: number;
|
||||
};
|
||||
|
||||
export type BuiltPsbt = {
|
||||
psbt: bitcoin.Psbt;
|
||||
/** Derivation paths in input order — pass straight to `signAndFinalize`. */
|
||||
inputPaths: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an unsigned PSBT. Input construction is per script type, exactly as Zeus's SweepStore does it:
|
||||
* legacy p2pkh needs the whole previous transaction (`nonWitnessUtxo`), segwit needs only the output
|
||||
* being spent (`witnessUtxo`), wrapped segwit additionally needs the p2wpkh `redeemScript`, and taproot
|
||||
* needs the x-only internal key so the signer can be matched against the output key.
|
||||
*/
|
||||
export function buildPsbt(params: BuildPsbtParams): BuiltPsbt {
|
||||
initEcc();
|
||||
const { network } = params;
|
||||
if (params.inputs.length === 0) throw new BackendError('cannot build a PSBT with no inputs', 400);
|
||||
if (params.outputs.length === 0) throw new BackendError('cannot build a PSBT with no outputs', 400);
|
||||
|
||||
const psbt = new bitcoin.Psbt({ network });
|
||||
psbt.setVersion(2);
|
||||
psbt.setLocktime(params.locktime ?? 0);
|
||||
|
||||
const sequence = params.rbf === false ? SEQUENCE_FINAL : SEQUENCE_RBF;
|
||||
const inputPaths: string[] = [];
|
||||
|
||||
for (const source of params.inputs) {
|
||||
const pubkey = Buffer.from(source.pubkeyHex, 'hex');
|
||||
const script = Buffer.from(source.scriptPubKeyHex, 'hex');
|
||||
const base = { hash: source.txid, index: source.vout, sequence };
|
||||
|
||||
switch (source.addressType) {
|
||||
case 'p2pkh': {
|
||||
if (!source.prevTxHex) {
|
||||
throw new BackendError(`p2pkh input ${source.txid}:${source.vout} needs the previous tx hex`, 500);
|
||||
}
|
||||
psbt.addInput({ ...base, nonWitnessUtxo: Buffer.from(source.prevTxHex, 'hex') });
|
||||
break;
|
||||
}
|
||||
case 'p2wpkh': {
|
||||
psbt.addInput({ ...base, witnessUtxo: { script, value: source.amountSats } });
|
||||
break;
|
||||
}
|
||||
case 'p2sh-p2wpkh': {
|
||||
const redeem = bitcoin.payments.p2wpkh({ pubkey, network });
|
||||
if (!redeem.output) throw new BackendError('failed to build the p2wpkh redeemScript', 500);
|
||||
psbt.addInput({
|
||||
...base,
|
||||
witnessUtxo: { script, value: source.amountSats },
|
||||
redeemScript: redeem.output,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'p2tr': {
|
||||
psbt.addInput({
|
||||
...base,
|
||||
witnessUtxo: { script, value: source.amountSats },
|
||||
tapInternalKey: toXOnly(pubkey),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
inputPaths.push(source.derivationPath);
|
||||
}
|
||||
|
||||
for (const output of params.outputs) {
|
||||
// Validate against the configured network before adding — bitcoinjs would accept a foreign-network
|
||||
// address encoded for a chain with the same prefixes and silently burn the funds.
|
||||
outputScriptFor(output.address, network);
|
||||
psbt.addOutput({ address: output.address, value: output.amountSats });
|
||||
}
|
||||
|
||||
return { psbt, inputPaths };
|
||||
}
|
||||
|
||||
// ── signing ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SignedTx = {
|
||||
txid: string;
|
||||
rawHex: string;
|
||||
/** Realised vsize of the signed transaction — compare against the estimate to audit fee accuracy. */
|
||||
vsize: number;
|
||||
feeSats: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* BIP341 key-path tweak. The output key is `P + H_TapTweak(P) * G` where P is the x-only internal key,
|
||||
* so the private key has to be tweaked the same way before it can produce a matching schnorr signature.
|
||||
* If the internal point has odd Y the scalar is negated first, since x-only keys are always even-Y.
|
||||
*/
|
||||
function tweakPrivateKey(priv: Uint8Array, pubkey: Buffer): Uint8Array {
|
||||
const even = pubkey[0] === 0x02 ? priv : ecc.privateNegate(priv);
|
||||
const tweak = bitcoin.crypto.taggedHash('TapTweak', toXOnly(pubkey));
|
||||
const tweaked = ecc.privateAdd(even, tweak);
|
||||
if (!tweaked) throw new BackendError('taproot tweak produced an invalid key', 500);
|
||||
return tweaked;
|
||||
}
|
||||
|
||||
function keyMaterial(node: HDKey): { priv: Uint8Array; pubkey: Buffer } {
|
||||
const priv = node.privateKey;
|
||||
const pub = node.publicKey;
|
||||
if (!priv || !pub) throw new BackendError('derived node has no private key — cannot sign', 500);
|
||||
return { priv, pubkey: Buffer.from(pub) };
|
||||
}
|
||||
|
||||
/** ECDSA signer for p2pkh / p2sh-p2wpkh / p2wpkh inputs. */
|
||||
function ecdsaSigner(node: HDKey): bitcoin.Signer {
|
||||
const { priv, pubkey } = keyMaterial(node);
|
||||
return {
|
||||
publicKey: pubkey,
|
||||
sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, priv)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnorr signer for a taproot key-path spend. `publicKey` must be the TWEAKED key: bitcoinjs matches
|
||||
* `toXOnly(signer.publicKey)` against the output key in the prevout (psbt.js getTaprootHashesForSig),
|
||||
* and the untweaked internal key would simply not match, failing with "Can not sign for input".
|
||||
*/
|
||||
function taprootSigner(node: HDKey): bitcoin.Signer {
|
||||
const { priv, pubkey } = keyMaterial(node);
|
||||
const tweakedPriv = tweakPrivateKey(priv, pubkey);
|
||||
const tweakedPub = ecc.pointFromScalar(tweakedPriv, true);
|
||||
if (!tweakedPub) throw new BackendError('taproot tweak produced an invalid point', 500);
|
||||
return {
|
||||
publicKey: Buffer.from(tweakedPub),
|
||||
sign: (hash: Buffer) => Buffer.from(ecc.sign(hash, tweakedPriv)),
|
||||
signSchnorr: (hash: Buffer) => Buffer.from(ecc.signSchnorr(hash, tweakedPriv)),
|
||||
};
|
||||
}
|
||||
|
||||
/** @scure/bip32 only accepts the apostrophe form of a hardened index. */
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/[hH]/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign every input from keys derived off `root`, finalise, and extract the transaction.
|
||||
*
|
||||
* `root` is a PARAMETER and is never cached, stored or closed over past this call: the caller holds the
|
||||
* key material and this function borrows it for the duration of a synchronous signing pass. Nothing
|
||||
* here awaits, so the root cannot be pinned alive by a pending network call.
|
||||
*/
|
||||
export function signAndFinalize(psbt: bitcoin.Psbt, root: HDKey, inputPaths: string[]): SignedTx {
|
||||
initEcc();
|
||||
const count = psbt.data.inputs.length;
|
||||
if (inputPaths.length !== count) {
|
||||
throw new BackendError(`expected ${count} derivation paths, got ${inputPaths.length}`, 500);
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const path = inputPaths[i];
|
||||
const input = psbt.data.inputs[i];
|
||||
if (path === undefined || input === undefined) {
|
||||
throw new BackendError(`missing derivation path for input #${i}`, 500);
|
||||
}
|
||||
const node = root.derive(normalizePath(path));
|
||||
// `tapInternalKey` is set only by the p2tr branch of buildPsbt, so it is the authoritative marker
|
||||
// for "this input needs a schnorr signature over a tweaked key".
|
||||
psbt.signInput(i, input.tapInternalKey ? taprootSigner(node) : ecdsaSigner(node));
|
||||
}
|
||||
|
||||
psbt.finalizeAllInputs();
|
||||
const feeSats = psbt.getFee();
|
||||
const tx = psbt.extractTransaction();
|
||||
return { txid: tx.getId(), rawHex: tx.toHex(), vsize: tx.virtualSize(), feeSats };
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { getWallet, getWalletSecrets, type WalletSummary } from 'officerdb';
|
||||
import { EsploraChain } from './chain';
|
||||
import { LndBackend } from './backends/lnd';
|
||||
import { ClnRestBackend } from './backends/clnrest';
|
||||
import { LndHubBackend } from './backends/lndhub';
|
||||
import { NwcBackend } from './backends/nwc';
|
||||
import { OnchainBackend } from './backends/onchain';
|
||||
import { sessionFor } from './keys';
|
||||
import { getConfig } from './upstream';
|
||||
import { BackendError, BIP_ADDRESS_TYPE, type AddressType, type BitcoinNetwork, type WalletBackend } from './types';
|
||||
|
||||
// Turns a stored wallet row into a live backend instance. This is the one place that knows the mapping
|
||||
// from `kind` to a class, and the one place node credentials are decrypted — getWalletSecrets() is
|
||||
// called here and the plaintext never travels further than the constructor it is handed to.
|
||||
//
|
||||
// Instances are cached per wallet id. Backends hold connection state worth reusing (LNDHub's access
|
||||
// token, NWC's relay socket, the on-chain gap-limit scan), and rebuilding one per request would both
|
||||
// re-authenticate constantly and defeat the address-scan cache. The cache is invalidated whenever the
|
||||
// wallet's config changes — see `invalidate()`, called from the update/delete routes.
|
||||
|
||||
type Cached = { backend: WalletBackend; configVersion: string };
|
||||
const cache = new Map<number, Cached>();
|
||||
|
||||
export function invalidate(walletId: number): void {
|
||||
cache.delete(walletId);
|
||||
}
|
||||
|
||||
export function invalidateAll(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/** A stable fingerprint of the inputs a backend was built from, so a stale instance is detected. */
|
||||
function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null): string {
|
||||
return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config]);
|
||||
}
|
||||
|
||||
export type Resolved = { wallet: WalletSummary; backend: WalletBackend };
|
||||
|
||||
export async function resolveBackend(userId: number, walletId: number): Promise<Resolved> {
|
||||
const wallet = await getWallet(userId, walletId);
|
||||
if (!wallet) throw new BackendError('wallet not found', 404, 'NOT_FOUND');
|
||||
|
||||
const secrets = await getWalletSecrets(userId, walletId);
|
||||
const config = secrets?.config ?? null;
|
||||
const version = versionOf(wallet, config);
|
||||
|
||||
const hit = cache.get(walletId);
|
||||
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };
|
||||
|
||||
const backend = build(wallet, config);
|
||||
cache.set(walletId, { backend, configVersion: version });
|
||||
return { wallet, backend };
|
||||
}
|
||||
|
||||
function required(config: Record<string, unknown> | null, key: string, kind: string): string {
|
||||
const v = config?.[key];
|
||||
if (typeof v !== 'string' || !v) {
|
||||
throw new BackendError(`${kind} wallet is missing required config "${key}"`, 400, 'BAD_CONFIG');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function build(wallet: WalletSummary, config: Record<string, unknown> | null): WalletBackend {
|
||||
const network = wallet.network as BitcoinNetwork;
|
||||
|
||||
switch (wallet.kind) {
|
||||
case 'lnd':
|
||||
return new LndBackend({
|
||||
url: required(config, 'url', 'lnd'),
|
||||
macaroonHex: required(config, 'macaroonHex', 'lnd'),
|
||||
allowSelfSigned: config?.allowSelfSigned === true,
|
||||
});
|
||||
|
||||
case 'cln-rest':
|
||||
return new ClnRestBackend({
|
||||
url: required(config, 'url', 'cln-rest'),
|
||||
rune: required(config, 'rune', 'cln-rest'),
|
||||
allowSelfSigned: config?.allowSelfSigned === true,
|
||||
});
|
||||
|
||||
case 'lndhub':
|
||||
return new LndHubBackend({
|
||||
url: required(config, 'url', 'lndhub'),
|
||||
login: required(config, 'login', 'lndhub'),
|
||||
password: required(config, 'password', 'lndhub'),
|
||||
});
|
||||
|
||||
case 'nwc':
|
||||
return new NwcBackend({ connectionUri: required(config, 'connectionUri', 'nwc') });
|
||||
|
||||
case 'onchain': {
|
||||
// Every xpub the wallet holds is handed over, not just the default BIP's. A seed derives all four
|
||||
// accounts (keys.ts::deriveAccountXpubs), and coins can legitimately sit on any of them — a
|
||||
// recovered seed may have been used with a p2tr wallet before, or received to a legacy address.
|
||||
// Scanning only the default account would silently under-report the balance and leave those UTXOs
|
||||
// unspendable. `defaultBip` then means only "which script type new receive addresses use".
|
||||
const accountXpub: Partial<Record<AddressType, string>> = {};
|
||||
for (const [bip, type] of Object.entries(BIP_ADDRESS_TYPE)) {
|
||||
const xpub = wallet.xpubs?.[bip];
|
||||
if (xpub) accountXpub[type] = xpub;
|
||||
}
|
||||
if (Object.keys(accountXpub).length === 0) {
|
||||
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
|
||||
}
|
||||
const { esploraUrl } = getConfig();
|
||||
return new OnchainBackend({
|
||||
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
|
||||
network,
|
||||
accountXpub,
|
||||
// The session is the signer. While locked it holds no key material, so watch-only reads below
|
||||
// still work and only sendCoins/signMessage will throw WalletLockedError.
|
||||
signer: sessionFor(wallet.id),
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import {
|
||||
listWallets,
|
||||
getWallet,
|
||||
getSealedSeed,
|
||||
createWallet,
|
||||
updateWallet,
|
||||
setActiveWallet,
|
||||
deleteWallet,
|
||||
getActiveWallet,
|
||||
getWalletLabels,
|
||||
setWalletLabel,
|
||||
getFrozenOutpoints,
|
||||
setUtxoFrozen,
|
||||
type WalletKind,
|
||||
} from 'officerdb';
|
||||
import { resolveBackend, invalidate } from './resolve';
|
||||
import {
|
||||
changePassphrase,
|
||||
deriveAccountXpubs,
|
||||
exportMnemonic,
|
||||
generateSeed,
|
||||
sealSeed,
|
||||
sessionFor,
|
||||
verifyPassphrase,
|
||||
type SeedEnvelope,
|
||||
} from './keys';
|
||||
import { getConfig, hasStoreKey } from './upstream';
|
||||
import {
|
||||
asAddressType,
|
||||
BackendError,
|
||||
WalletLockedError,
|
||||
BIP_ADDRESS_TYPE,
|
||||
type BitcoinNetwork,
|
||||
type Capability,
|
||||
type Utxo,
|
||||
type WalletBackend,
|
||||
} from './types';
|
||||
|
||||
// The wallet sidecar's route surface. Every route is scoped to the authenticated owner via X-Officer-User,
|
||||
// which the platform proxy injects (src/servers/api/wallet/router.ts) and which is trustworthy because the
|
||||
// sidecar binds loopback only.
|
||||
//
|
||||
// Route ordering matters here: the seed/lock routes are matched BEFORE the generic wallet operations, so a
|
||||
// wallet named "unlock" can never shadow the unlock endpoint.
|
||||
//
|
||||
// SECURITY NOTES that apply to this whole file:
|
||||
// - No route ever returns a mnemonic, a sealed envelope, a node macaroon, or an unlock passphrase,
|
||||
// except /export-seed, which exists for backup and demands the passphrase every single time.
|
||||
// - Passphrases arrive in request bodies and are never logged. The catch-all handler at the bottom logs
|
||||
// the method and path only, deliberately not the body.
|
||||
// - Capability checks happen before dispatch so an unsupported operation is a clean 501.
|
||||
|
||||
export type OfficerContext = { req: Request; url: URL; userId: number };
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return Response.json(data as Record<string, unknown>, { status });
|
||||
}
|
||||
|
||||
function badRequest(message: string): Response {
|
||||
return json({ error: message }, 400);
|
||||
}
|
||||
|
||||
async function body<T>(req: Request): Promise<T> {
|
||||
try {
|
||||
return (await req.json()) as T;
|
||||
} catch {
|
||||
throw new BackendError('expected a JSON body', 400, 'BAD_BODY');
|
||||
}
|
||||
}
|
||||
|
||||
/** Guard a capability before dispatching, so callers get 501 rather than a confusing upstream error. */
|
||||
function requireCap(backend: WalletBackend, cap: Capability, op: string): void {
|
||||
if (!backend.supports(cap)) {
|
||||
throw new BackendError(`this wallet does not support ${op}`, 501, 'NOT_SUPPORTED');
|
||||
}
|
||||
}
|
||||
|
||||
// ── entry point ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
if (!officerUser) return json({ error: 'missing X-Officer-User' }, 401);
|
||||
const userId = Number(officerUser);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
|
||||
|
||||
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
const ctx: OfficerContext = { req, url, userId };
|
||||
|
||||
try {
|
||||
switch (segments[0]) {
|
||||
case 'config':
|
||||
return handleConfig();
|
||||
case 'wallets':
|
||||
return await handleWallets(ctx, segments.slice(1));
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError) {
|
||||
return json({ error: err.message, code: err.code }, err.status);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-secret deployment facts the UI needs before any wallet exists. */
|
||||
function handleConfig(): Response {
|
||||
const cfg = getConfig();
|
||||
return json({
|
||||
network: cfg.network,
|
||||
esploraUrl: cfg.esploraUrl,
|
||||
unlockTtlSec: cfg.unlockTtlSec,
|
||||
// The UI blocks wallet creation on this rather than letting the first write fail on a crypto error.
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── /wallets ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const KINDS: readonly WalletKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];
|
||||
|
||||
async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Response | null> {
|
||||
const { req, userId } = ctx;
|
||||
|
||||
// /_officer/wallets
|
||||
if (seg.length === 0) {
|
||||
if (req.method === 'GET') return json({ wallets: await listWallets(userId) });
|
||||
if (req.method === 'POST') return await createWalletRoute(ctx);
|
||||
return json({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
|
||||
// /_officer/wallets/active — resolves to whichever wallet is currently selected
|
||||
if (seg[0] === 'active' && seg.length === 1 && req.method === 'GET') {
|
||||
const active = await getActiveWallet(userId);
|
||||
return json({ wallet: active });
|
||||
}
|
||||
|
||||
const walletId = Number(seg[0]);
|
||||
if (!Number.isInteger(walletId) || walletId <= 0) return badRequest('invalid wallet id');
|
||||
const rest = seg.slice(1);
|
||||
|
||||
// /_officer/wallets/:id
|
||||
if (rest.length === 0) {
|
||||
if (req.method === 'GET') {
|
||||
const wallet = await getWallet(userId, walletId);
|
||||
return wallet ? json({ wallet }) : json({ error: 'wallet not found' }, 404);
|
||||
}
|
||||
if (req.method === 'PATCH') {
|
||||
const patch = await body<{ name?: string; defaultBip?: number; config?: Record<string, unknown> }>(req);
|
||||
const updated = await updateWallet(userId, walletId, patch);
|
||||
invalidate(walletId);
|
||||
return updated ? json({ wallet: updated }) : json({ error: 'wallet not found' }, 404);
|
||||
}
|
||||
if (req.method === 'DELETE') return await deleteWalletRoute(ctx, walletId);
|
||||
return json({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
|
||||
// Seed / lock lifecycle — matched before the generic operations below.
|
||||
switch (rest[0]) {
|
||||
case 'activate':
|
||||
if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
await setActiveWallet(userId, walletId);
|
||||
return json({ ok: true });
|
||||
|
||||
case 'lock-state': {
|
||||
const wallet = await getWallet(userId, walletId);
|
||||
if (!wallet) return json({ error: 'wallet not found' }, 404);
|
||||
const session = sessionFor(walletId);
|
||||
return json({
|
||||
hasSeed: wallet.hasSeed,
|
||||
unlocked: session.isUnlocked(),
|
||||
secondsRemaining: session.secondsRemaining(),
|
||||
});
|
||||
}
|
||||
|
||||
case 'unlock':
|
||||
return await unlockRoute(ctx, walletId);
|
||||
|
||||
case 'lock':
|
||||
if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
sessionFor(walletId).lock();
|
||||
return json({ ok: true, unlocked: false });
|
||||
|
||||
case 'passphrase':
|
||||
return await changePassphraseRoute(ctx, walletId);
|
||||
|
||||
case 'export-seed':
|
||||
return await exportSeedRoute(ctx, walletId);
|
||||
}
|
||||
|
||||
// Everything else needs a live backend.
|
||||
const { wallet, backend } = await resolveBackend(userId, walletId);
|
||||
|
||||
switch (rest[0]) {
|
||||
case 'capabilities': {
|
||||
const all: Capability[] = [
|
||||
'onchainReceive',
|
||||
'onchainSend',
|
||||
'coinControl',
|
||||
'psbt',
|
||||
'bumpFee',
|
||||
'sweep',
|
||||
'accounts',
|
||||
'lightningReceive',
|
||||
'lightningSend',
|
||||
'keysend',
|
||||
'customPreimages',
|
||||
'offers',
|
||||
'channels',
|
||||
'peers',
|
||||
'routing',
|
||||
'signMessage',
|
||||
];
|
||||
return json({ kind: wallet.kind, capabilities: all.filter((c) => backend.supports(c)) });
|
||||
}
|
||||
|
||||
case 'info':
|
||||
return json({ info: await backend.getInfo() });
|
||||
|
||||
case 'balances':
|
||||
return json({ balances: await backend.getBalances() });
|
||||
|
||||
case 'transactions': {
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
const txs = await backend.getTransactions({ limit });
|
||||
// Overlay owner labels, which live in Officer's DB rather than any backend.
|
||||
const labels = await getWalletLabels(walletId);
|
||||
const byRef = new Map(labels.filter((l) => l.kind === 'tx').map((l) => [l.ref, l.label]));
|
||||
return json({ transactions: txs.map((t) => ({ ...t, label: byRef.get(t.txid) ?? t.label })) });
|
||||
}
|
||||
|
||||
case 'address': {
|
||||
requireCap(backend, 'onchainReceive', 'receiving on-chain');
|
||||
const peek = ctx.url.searchParams.get('peek') === 'true';
|
||||
// An explicit ?type= wins; otherwise the wallet's defaultBip decides. Without this the on-chain
|
||||
// backend would always fall back to its own preference order (native segwit), silently ignoring an
|
||||
// owner who set the wallet to taproot or legacy.
|
||||
const asked = ctx.url.searchParams.get('type');
|
||||
if (asked && !asAddressType(asked)) return badRequest(`unknown address type "${asked}"`);
|
||||
const type = asked ? asAddressType(asked) : BIP_ADDRESS_TYPE[String(wallet.defaultBip)];
|
||||
return json(await backend.getNewAddress({ peek, type }));
|
||||
}
|
||||
|
||||
case 'utxos':
|
||||
return await utxosRoute(ctx, walletId, backend, rest.slice(1));
|
||||
|
||||
case 'fees':
|
||||
return json({ fees: await backend.estimateFees() });
|
||||
|
||||
case 'send': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'onchainSend', 'sending on-chain');
|
||||
// The lock gate comes before body validation, not after. The backend checks it too, but only once
|
||||
// the request has already passed every field check here — so a locked wallet was answering "your
|
||||
// fee rate is wrong" instead of "unlock me first", which sends the UI down the wrong path.
|
||||
if (wallet.hasSeed && !sessionFor(walletId).isUnlocked()) throw new WalletLockedError();
|
||||
const req2 = await body<Parameters<WalletBackend['sendCoins']>[0]>(ctx.req);
|
||||
if (!req2.address) return badRequest('address is required');
|
||||
if (!req2.sendAll && !req2.amountSats) return badRequest('amountSats or sendAll is required');
|
||||
if (!req2.satPerVbyte || req2.satPerVbyte < 1) return badRequest('satPerVbyte must be at least 1');
|
||||
// Never spend a frozen coin, even if the caller passed no explicit outpoint list.
|
||||
const frozen = new Set(await getFrozenOutpoints(walletId));
|
||||
if (req2.outpoints?.some((o) => frozen.has(o))) return badRequest('refusing to spend a frozen UTXO');
|
||||
const result = await backend.sendCoins(req2);
|
||||
if (req2.label) await setWalletLabel(walletId, 'tx', result.txid, req2.label);
|
||||
return json(result);
|
||||
}
|
||||
|
||||
case 'invoices':
|
||||
return await invoicesRoute(ctx, backend, rest.slice(1));
|
||||
|
||||
case 'decode': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { bolt11 } = await body<{ bolt11?: string }>(ctx.req);
|
||||
if (!bolt11) return badRequest('bolt11 is required');
|
||||
return json({ decoded: await backend.decodeInvoice(bolt11) });
|
||||
}
|
||||
|
||||
case 'payments': {
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
return json({ payments: await backend.getPayments({ limit }) });
|
||||
}
|
||||
|
||||
case 'pay': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'lightningSend', 'paying lightning invoices');
|
||||
const payReq = await body<Parameters<WalletBackend['payInvoice']>[0]>(ctx.req);
|
||||
if (!payReq.bolt11) return badRequest('bolt11 is required');
|
||||
if (payReq.feeLimitMsat && payReq.feeLimitPercent) {
|
||||
return badRequest('feeLimitMsat and feeLimitPercent are mutually exclusive');
|
||||
}
|
||||
return json({ payment: await backend.payInvoice(payReq) });
|
||||
}
|
||||
|
||||
case 'keysend': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'keysend', 'keysend');
|
||||
const ks = await body<Parameters<WalletBackend['sendKeysend']>[0]>(ctx.req);
|
||||
if (!ks.destination || !ks.amountMsat) return badRequest('destination and amountMsat are required');
|
||||
return json({ payment: await backend.sendKeysend(ks) });
|
||||
}
|
||||
|
||||
case 'channels':
|
||||
requireCap(backend, 'channels', 'channels');
|
||||
return json({ channels: await backend.getChannels() });
|
||||
|
||||
case 'peers':
|
||||
requireCap(backend, 'peers', 'peers');
|
||||
return json({ peers: await backend.getPeers() });
|
||||
|
||||
case 'sign': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'signMessage', 'message signing');
|
||||
const { message } = await body<{ message?: string }>(ctx.req);
|
||||
if (!message) return badRequest('message is required');
|
||||
return json(await backend.signMessage(message));
|
||||
}
|
||||
|
||||
case 'verify': {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
requireCap(backend, 'signMessage', 'message verification');
|
||||
const { message, signature } = await body<{ message?: string; signature?: string }>(ctx.req);
|
||||
if (!message || !signature) return badRequest('message and signature are required');
|
||||
return json(await backend.verifyMessage(message, signature));
|
||||
}
|
||||
|
||||
case 'labels': {
|
||||
if (ctx.req.method === 'GET') return json({ labels: await getWalletLabels(walletId) });
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { kind, ref, label } = await body<{ kind?: string; ref?: string; label?: string }>(ctx.req);
|
||||
if (kind !== 'address' && kind !== 'tx') return badRequest('kind must be "address" or "tx"');
|
||||
if (!ref) return badRequest('ref is required');
|
||||
await setWalletLabel(walletId, kind, ref, label ?? '');
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── wallet lifecycle ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type CreateBody = {
|
||||
name?: string;
|
||||
kind?: string;
|
||||
network?: string;
|
||||
/** onchain only: omit `mnemonic` to generate a fresh seed. */
|
||||
mnemonic?: string;
|
||||
words?: 12 | 24;
|
||||
passphrase?: string;
|
||||
bip39Passphrase?: string;
|
||||
defaultBip?: number;
|
||||
config?: Record<string, unknown>;
|
||||
makeActive?: boolean;
|
||||
};
|
||||
|
||||
async function createWalletRoute(ctx: OfficerContext): Promise<Response> {
|
||||
if (!hasStoreKey()) {
|
||||
throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY');
|
||||
}
|
||||
const b = await body<CreateBody>(ctx.req);
|
||||
if (!b.name?.trim()) return badRequest('name is required');
|
||||
if (!b.kind || !(KINDS as readonly string[]).includes(b.kind)) {
|
||||
return badRequest(`kind must be one of ${KINDS.join(', ')}`);
|
||||
}
|
||||
const kind = b.kind as WalletKind;
|
||||
const network = (b.network ?? getConfig().network) as BitcoinNetwork;
|
||||
|
||||
// Remote-node wallets: store the connection config, no seed involved.
|
||||
if (kind !== 'onchain') {
|
||||
if (!b.config) return badRequest(`${kind} wallets require a config object`);
|
||||
const wallet = await createWallet({
|
||||
userId: ctx.userId,
|
||||
name: b.name.trim(),
|
||||
kind,
|
||||
network,
|
||||
config: b.config,
|
||||
makeActive: b.makeActive ?? true,
|
||||
});
|
||||
return json({ wallet }, 201);
|
||||
}
|
||||
|
||||
// Self-custodial on-chain wallet: seal a seed under the owner passphrase.
|
||||
if (!b.passphrase) return badRequest('passphrase is required for a seeded wallet');
|
||||
const mnemonic = b.mnemonic?.trim() || generateSeed(b.words ?? 24);
|
||||
const envelope = await sealSeed(mnemonic, b.passphrase, b.bip39Passphrase);
|
||||
const { fingerprint, xpubs } = await deriveAccountXpubs(envelope, b.passphrase, network);
|
||||
|
||||
const wallet = await createWallet({
|
||||
userId: ctx.userId,
|
||||
name: b.name.trim(),
|
||||
kind,
|
||||
network,
|
||||
sealedSeed: JSON.stringify(envelope),
|
||||
fingerprint,
|
||||
xpubs: Object.fromEntries(Object.entries(xpubs).map(([k, v]) => [k, v])),
|
||||
defaultBip: b.defaultBip ?? 84,
|
||||
makeActive: b.makeActive ?? true,
|
||||
});
|
||||
|
||||
// Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and
|
||||
// has no other chance to see it without re-entering the passphrase. An imported mnemonic is never
|
||||
// echoed back: the caller already has it, and echoing would put it in a response log for no reason.
|
||||
return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201);
|
||||
}
|
||||
|
||||
async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
const wallet = await getWallet(ctx.userId, walletId);
|
||||
if (!wallet) return json({ error: 'wallet not found' }, 404);
|
||||
|
||||
// Deleting a seeded wallet destroys the only copy of the key material Officer holds. Require the
|
||||
// passphrase, even when the wallet is already unlocked — an open session must not be enough.
|
||||
if (wallet.hasSeed) {
|
||||
const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
|
||||
if (!passphrase) return badRequest('passphrase is required to delete a seeded wallet');
|
||||
const sealed = await getSealedSeed(ctx.userId, walletId);
|
||||
if (!sealed) throw new BackendError('wallet seed is missing', 500, 'NO_SEED');
|
||||
if (!(await verifyPassphrase(JSON.parse(sealed) as SeedEnvelope, passphrase))) {
|
||||
return json({ error: 'incorrect passphrase' }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
sessionFor(walletId).lock();
|
||||
invalidate(walletId);
|
||||
const deleted = await deleteWallet(ctx.userId, walletId);
|
||||
return deleted ? json({ ok: true }) : json({ error: 'wallet not found' }, 404);
|
||||
}
|
||||
|
||||
// ── lock lifecycle ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadEnvelope(userId: number, walletId: number): Promise<SeedEnvelope> {
|
||||
const sealed = await getSealedSeed(userId, walletId);
|
||||
if (!sealed) throw new BackendError('this wallet holds no seed', 400, 'NO_SEED');
|
||||
return JSON.parse(sealed) as SeedEnvelope;
|
||||
}
|
||||
|
||||
async function unlockRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { passphrase, ttlSec } = await body<{ passphrase?: string; ttlSec?: number }>(ctx.req);
|
||||
if (!passphrase) return badRequest('passphrase is required');
|
||||
|
||||
const env = await loadEnvelope(ctx.userId, walletId);
|
||||
const session = sessionFor(walletId);
|
||||
const max = getConfig().unlockTtlSec;
|
||||
// A caller may shorten the window but never extend it past the deployment's configured maximum.
|
||||
await session.unlock(env, passphrase, Math.min(ttlSec ?? max, max));
|
||||
|
||||
return json({ ok: true, unlocked: true, secondsRemaining: session.secondsRemaining() });
|
||||
}
|
||||
|
||||
async function changePassphraseRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { oldPassphrase, newPassphrase } = await body<{ oldPassphrase?: string; newPassphrase?: string }>(ctx.req);
|
||||
if (!oldPassphrase || !newPassphrase) return badRequest('oldPassphrase and newPassphrase are required');
|
||||
|
||||
const env = await loadEnvelope(ctx.userId, walletId);
|
||||
const resealed = await changePassphrase(env, oldPassphrase, newPassphrase);
|
||||
await updateWallet(ctx.userId, walletId, { sealedSeed: JSON.stringify(resealed) });
|
||||
// Force a re-unlock under the new passphrase rather than leaving a session opened by the old one.
|
||||
sessionFor(walletId).lock();
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
async function exportSeedRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
|
||||
if (!passphrase) return badRequest('passphrase is required');
|
||||
|
||||
const env = await loadEnvelope(ctx.userId, walletId);
|
||||
const mnemonic = await exportMnemonic(env, passphrase);
|
||||
console.warn(`[wallet] seed exported for wallet ${walletId} by user ${ctx.userId}`);
|
||||
return json({ mnemonic, hasBip39Passphrase: env.hasBip39Passphrase });
|
||||
}
|
||||
|
||||
// ── utxos ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function utxosRoute(
|
||||
ctx: OfficerContext,
|
||||
walletId: number,
|
||||
backend: WalletBackend,
|
||||
seg: string[],
|
||||
): Promise<Response> {
|
||||
requireCap(backend, 'coinControl', 'coin control');
|
||||
|
||||
if (seg[0] === 'freeze') {
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
const { outpoint, frozen, reason } = await body<{ outpoint?: string; frozen?: boolean; reason?: string }>(ctx.req);
|
||||
if (!outpoint || !/^[0-9a-f]{64}:\d+$/i.test(outpoint)) return badRequest('outpoint must be "txid:vout"');
|
||||
await setUtxoFrozen(walletId, outpoint, frozen !== false, reason);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
const [utxos, frozenList, labels] = await Promise.all([
|
||||
backend.getUtxos(),
|
||||
getFrozenOutpoints(walletId),
|
||||
getWalletLabels(walletId),
|
||||
]);
|
||||
const frozen = new Set(frozenList);
|
||||
const byAddr = new Map(labels.filter((l) => l.kind === 'address').map((l) => [l.ref, l.label]));
|
||||
|
||||
// The freeze flag is Officer's, not the backend's — overlay it here so coin control is consistent
|
||||
// across every backend, including ones with no freeze concept of their own.
|
||||
const merged: (Utxo & { label: string | null })[] = utxos.map((u) => ({
|
||||
...u,
|
||||
frozen: frozen.has(`${u.txid}:${u.vout}`),
|
||||
label: byAddr.get(u.address) ?? null,
|
||||
}));
|
||||
return json({ utxos: merged });
|
||||
}
|
||||
|
||||
// ── invoices ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function invoicesRoute(ctx: OfficerContext, backend: WalletBackend, seg: string[]): Promise<Response> {
|
||||
// /_officer/wallets/:id/invoices/:paymentHash
|
||||
if (seg.length === 1) {
|
||||
const invoice = await backend.lookupInvoice(seg[0]!);
|
||||
return invoice ? json({ invoice }) : json({ error: 'invoice not found' }, 404);
|
||||
}
|
||||
|
||||
if (ctx.req.method === 'GET') {
|
||||
requireCap(backend, 'lightningReceive', 'lightning');
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
return json({ invoices: await backend.getInvoices({ limit }) });
|
||||
}
|
||||
|
||||
if (ctx.req.method === 'POST') {
|
||||
requireCap(backend, 'lightningReceive', 'creating invoices');
|
||||
const req = await body<Parameters<WalletBackend['createInvoice']>[0]>(ctx.req);
|
||||
if (req.preimage) requireCap(backend, 'customPreimages', 'custom preimages');
|
||||
return json({ invoice: await backend.createInvoice(req) }, 201);
|
||||
}
|
||||
|
||||
return json({ error: 'method not allowed' }, 405);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// The wallet sidecar's backend contract — a typed re-statement of what Zeus's utils/BackendUtils.ts
|
||||
// dispatches to duck-typed across its seven backends.
|
||||
//
|
||||
// Two things are deliberately NOT copied from Zeus:
|
||||
//
|
||||
// 1. Zeus's `call(funcName, args)` returns `false` when a backend lacks a method, so an unsupported
|
||||
// feature and a typo'd method name are indistinguishable at the call site. Here the surface is a
|
||||
// TypeScript interface and capability negotiation is explicit via `supports()`.
|
||||
// 2. Zeus threads URL path segments through the interface — `decodePaymentRequest(urlParams[0])`,
|
||||
// `closeChannel(urlParams)`. Every operation below takes a named request object.
|
||||
//
|
||||
// UNITS. Amounts are satoshis as `number` (max 2.1e15, safely inside 2^53) and millisatoshis as
|
||||
// decimal `string` (2.1e18 overflows a double). Never widen a msat to a number.
|
||||
|
||||
export type BackendKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
|
||||
|
||||
export type BitcoinNetwork = 'bitcoin' | 'testnet' | 'signet' | 'regtest';
|
||||
|
||||
/**
|
||||
* Explicit capability flags, replacing Zeus's ~60 `supportsX()` predicates (BackendUtils.ts:200-270).
|
||||
* A backend declares these once; routes.ts checks before dispatching so an unsupported operation is a
|
||||
* clean 501 rather than a mystery failure deep in an upstream call.
|
||||
*/
|
||||
export type Capability =
|
||||
// on-chain
|
||||
| 'onchainReceive'
|
||||
| 'onchainSend'
|
||||
| 'coinControl'
|
||||
| 'psbt'
|
||||
| 'bumpFee'
|
||||
| 'sweep'
|
||||
| 'accounts'
|
||||
// lightning
|
||||
| 'lightningReceive'
|
||||
| 'lightningSend'
|
||||
| 'keysend'
|
||||
| 'customPreimages'
|
||||
| 'offers'
|
||||
// node operation
|
||||
| 'channels'
|
||||
| 'peers'
|
||||
| 'routing'
|
||||
| 'signMessage';
|
||||
|
||||
// ── node / balances ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NodeInfo = {
|
||||
kind: BackendKind;
|
||||
/** Node pubkey, or null for backends with no node identity of their own (onchain, lndhub). */
|
||||
pubkey: string | null;
|
||||
alias: string | null;
|
||||
/** Upstream implementation version, verbatim. Null when the backend cannot report one. */
|
||||
version: string | null;
|
||||
network: BitcoinNetwork;
|
||||
blockHeight: number | null;
|
||||
synced: boolean;
|
||||
};
|
||||
|
||||
export type Balances = {
|
||||
/** Spendable on-chain, in sats. */
|
||||
onchainConfirmed: number;
|
||||
onchainUnconfirmed: number;
|
||||
/** Sum of local balance across active channels, in sats. Null when the backend has no channels. */
|
||||
lightningBalance: number | null;
|
||||
/** Sum of remote balance — i.e. inbound liquidity — in sats. */
|
||||
lightningInbound: number | null;
|
||||
};
|
||||
|
||||
// ── on-chain ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
|
||||
|
||||
/**
|
||||
* BIP44 purpose → the script type it derives. Keyed by the string form, because that is how a wallet's
|
||||
* `xpubs` map arrives from the database (JSON object keys are always strings).
|
||||
*/
|
||||
export const BIP_ADDRESS_TYPE: Record<string, AddressType> = {
|
||||
'44': 'p2pkh',
|
||||
'49': 'p2sh-p2wpkh',
|
||||
'84': 'p2wpkh',
|
||||
'86': 'p2tr',
|
||||
};
|
||||
|
||||
/** Narrow caller-supplied text to an AddressType, or undefined if it names none. */
|
||||
export function asAddressType(value: string): AddressType | undefined {
|
||||
return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh' ? value : undefined;
|
||||
}
|
||||
|
||||
export type OnchainTx = {
|
||||
txid: string;
|
||||
/** Net effect on this wallet in sats — negative for a spend. */
|
||||
amount: number;
|
||||
feeSats: number | null;
|
||||
blockHeight: number | null;
|
||||
/** Unix seconds. Null while unconfirmed on backends that do not timestamp the mempool. */
|
||||
timestamp: number | null;
|
||||
confirmations: number;
|
||||
label: string | null;
|
||||
destAddresses: string[];
|
||||
/** Raw tx hex when the backend supplies it, for RBF/inspection. */
|
||||
rawHex: string | null;
|
||||
};
|
||||
|
||||
export type Utxo = {
|
||||
txid: string;
|
||||
vout: number;
|
||||
amountSats: number;
|
||||
address: string;
|
||||
addressType: AddressType | null;
|
||||
confirmations: number;
|
||||
/** BIP32 path relative to the account xpub, when this UTXO belongs to a derived address. */
|
||||
derivationPath: string | null;
|
||||
/** Frozen UTXOs are excluded from automatic coin selection. */
|
||||
frozen: boolean;
|
||||
};
|
||||
|
||||
export type FeeEstimates = {
|
||||
/** sat/vB, keyed by confirmation target in blocks. */
|
||||
fastestFee: number;
|
||||
halfHourFee: number;
|
||||
hourFee: number;
|
||||
economyFee: number;
|
||||
minimumFee: number;
|
||||
};
|
||||
|
||||
export type NewAddressRequest = {
|
||||
type?: AddressType;
|
||||
/** Return the current unused address instead of advancing the derivation index. */
|
||||
peek?: boolean;
|
||||
};
|
||||
|
||||
export type SendCoinsRequest = {
|
||||
address: string;
|
||||
/** Ignored when `sendAll` is set. */
|
||||
amountSats?: number;
|
||||
sendAll?: boolean;
|
||||
satPerVbyte: number;
|
||||
/** Coin control: restrict inputs to these outpoints (`txid:vout`). Requires the `coinControl` capability. */
|
||||
outpoints?: string[];
|
||||
spendUnconfirmed?: boolean;
|
||||
/** Opt out of RBF signalling. Default is replaceable. */
|
||||
rbf?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type SendCoinsResult = {
|
||||
txid: string;
|
||||
feeSats: number;
|
||||
rawHex: string | null;
|
||||
};
|
||||
|
||||
// ── lightning ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type InvoiceState = 'open' | 'settled' | 'canceled' | 'accepted' | 'expired';
|
||||
|
||||
export type Invoice = {
|
||||
/** Payment hash, hex. */
|
||||
paymentHash: string;
|
||||
/** BOLT11 payment request. */
|
||||
bolt11: string;
|
||||
amountMsat: string | null;
|
||||
amountPaidMsat: string | null;
|
||||
memo: string | null;
|
||||
state: InvoiceState;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
settledAt: number | null;
|
||||
/** Only ever populated for invoices this wallet created and only when explicitly requested. */
|
||||
preimage: string | null;
|
||||
isKeysend: boolean;
|
||||
isAmp: boolean;
|
||||
};
|
||||
|
||||
export type CreateInvoiceRequest = {
|
||||
amountMsat?: string;
|
||||
memo?: string;
|
||||
expirySeconds?: number;
|
||||
/** Requires the `customPreimages` capability. */
|
||||
preimage?: string;
|
||||
isAmp?: boolean;
|
||||
private?: boolean;
|
||||
};
|
||||
|
||||
export type DecodedInvoice = {
|
||||
bolt11: string;
|
||||
paymentHash: string;
|
||||
amountMsat: string | null;
|
||||
description: string | null;
|
||||
destination: string;
|
||||
timestamp: number;
|
||||
expiry: number;
|
||||
cltvExpiry: number | null;
|
||||
routeHints: boolean;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
export type PaymentStatus = 'pending' | 'succeeded' | 'failed';
|
||||
|
||||
export type Payment = {
|
||||
paymentHash: string;
|
||||
preimage: string | null;
|
||||
amountMsat: string;
|
||||
feeMsat: string;
|
||||
status: PaymentStatus;
|
||||
createdAt: number;
|
||||
destination: string | null;
|
||||
memo: string | null;
|
||||
/** Upstream failure reason, verbatim, when status is 'failed'. */
|
||||
failureReason: string | null;
|
||||
};
|
||||
|
||||
export type PayInvoiceRequest = {
|
||||
bolt11: string;
|
||||
/** Required when the invoice is zero-amount; rejected otherwise. */
|
||||
amountMsat?: string;
|
||||
/** Absolute cap in msat. Mutually exclusive with feeLimitPercent. */
|
||||
feeLimitMsat?: string;
|
||||
feeLimitPercent?: number;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
export type KeysendRequest = {
|
||||
destination: string;
|
||||
amountMsat: string;
|
||||
feeLimitMsat?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
// ── channels / peers ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Channel = {
|
||||
channelId: string;
|
||||
channelPoint: string | null;
|
||||
remotePubkey: string;
|
||||
remoteAlias: string | null;
|
||||
capacitySats: number;
|
||||
localBalanceSats: number;
|
||||
remoteBalanceSats: number;
|
||||
active: boolean;
|
||||
private: boolean;
|
||||
/** 'open' | 'pending-open' | 'pending-close' | 'force-closing' | 'closed' */
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Peer = {
|
||||
pubkey: string;
|
||||
address: string;
|
||||
alias: string | null;
|
||||
inbound: boolean;
|
||||
};
|
||||
|
||||
// ── signing ──────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SignMessageResult = { signature: string };
|
||||
export type VerifyMessageResult = { valid: boolean; pubkey: string | null };
|
||||
|
||||
// ── the interface ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every method may throw `BackendError`. Methods guarded by a capability must only be called after
|
||||
* `supports()` returns true — the base class throws `notSupported()` otherwise, so a missed guard is a
|
||||
* loud 501 rather than Zeus's silent `false`.
|
||||
*/
|
||||
export interface WalletBackend {
|
||||
readonly kind: BackendKind;
|
||||
|
||||
supports(cap: Capability): boolean;
|
||||
|
||||
getInfo(): Promise<NodeInfo>;
|
||||
getBalances(): Promise<Balances>;
|
||||
|
||||
// on-chain
|
||||
getTransactions(opts?: { limit?: number }): Promise<OnchainTx[]>;
|
||||
getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }>;
|
||||
getUtxos(): Promise<Utxo[]>;
|
||||
estimateFees(): Promise<FeeEstimates>;
|
||||
sendCoins(req: SendCoinsRequest): Promise<SendCoinsResult>;
|
||||
|
||||
// lightning
|
||||
getInvoices(opts?: { limit?: number }): Promise<Invoice[]>;
|
||||
createInvoice(req: CreateInvoiceRequest): Promise<Invoice>;
|
||||
lookupInvoice(paymentHash: string): Promise<Invoice | null>;
|
||||
decodeInvoice(bolt11: string): Promise<DecodedInvoice>;
|
||||
getPayments(opts?: { limit?: number }): Promise<Payment[]>;
|
||||
payInvoice(req: PayInvoiceRequest): Promise<Payment>;
|
||||
sendKeysend(req: KeysendRequest): Promise<Payment>;
|
||||
|
||||
// node operation
|
||||
getChannels(): Promise<Channel[]>;
|
||||
getPeers(): Promise<Peer[]>;
|
||||
|
||||
signMessage(message: string): Promise<SignMessageResult>;
|
||||
verifyMessage(message: string, signature: string): Promise<VerifyMessageResult>;
|
||||
}
|
||||
|
||||
// ── errors ───────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export class BackendError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number = 502,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BackendError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The wallet holds a seed but is currently locked — signing is impossible until /unlock. */
|
||||
export class WalletLockedError extends BackendError {
|
||||
constructor() {
|
||||
super('wallet is locked', 423, 'WALLET_LOCKED');
|
||||
this.name = 'WalletLockedError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { BitcoinNetwork } from './types';
|
||||
|
||||
// The ONLY reader of WALLET_* env in the tree. Everything else — node URLs, macaroons, runes, LNDHub
|
||||
// credentials, NWC URIs — is per-wallet configuration the owner enters at runtime and lives encrypted in
|
||||
// Postgres (databases/officer_db/src/schema/wallet.ts), not here. Env holds only what is genuinely
|
||||
// deployment-wide: which chain we're on and where to get chain data.
|
||||
|
||||
const NETWORKS: readonly BitcoinNetwork[] = ['bitcoin', 'testnet', 'signet', 'regtest'];
|
||||
|
||||
// mempool.space's public API. Fine to start on; swap it for your own electrs/esplora when the node is up
|
||||
// — an Esplora endpoint sees every address in the wallet, so the public one is a privacy leak, not a
|
||||
// custody one. It never sees a private key and cannot authorize anything.
|
||||
const DEFAULT_ESPLORA: Record<BitcoinNetwork, string> = {
|
||||
bitcoin: 'https://mempool.space/api',
|
||||
testnet: 'https://mempool.space/testnet/api',
|
||||
signet: 'https://mempool.space/signet/api',
|
||||
regtest: 'http://127.0.0.1:3002',
|
||||
};
|
||||
|
||||
export type WalletConfig = {
|
||||
network: BitcoinNetwork;
|
||||
esploraUrl: string;
|
||||
unlockTtlSec: number;
|
||||
};
|
||||
|
||||
let warned = false;
|
||||
|
||||
export function getConfig(): WalletConfig {
|
||||
const raw = process.env.WALLET_NETWORK?.trim() ?? 'bitcoin';
|
||||
const network = (NETWORKS as readonly string[]).includes(raw) ? (raw as BitcoinNetwork) : 'bitcoin';
|
||||
if (raw && network !== raw && !warned) {
|
||||
console.warn(`[wallet] WALLET_NETWORK="${raw}" is not a known network, falling back to bitcoin`);
|
||||
warned = true;
|
||||
}
|
||||
|
||||
const esploraUrl = process.env.WALLET_ESPLORA_URL?.trim().replace(/\/+$/, '') || DEFAULT_ESPLORA[network];
|
||||
const ttl = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900);
|
||||
|
||||
return {
|
||||
network,
|
||||
esploraUrl,
|
||||
// Clamp: a zero TTL makes the wallet unusable, and an unbounded one defeats auto-lock entirely.
|
||||
unlockTtlSec: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 30), 86_400) : 900,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether VAULT_STORE_KEY is present. The sidecar can serve a locked, watch-only view without it, but
|
||||
* every write path that touches an encrypted column will throw, so /_health reports it explicitly rather
|
||||
* than letting the first wallet creation fail with a confusing crypto error.
|
||||
*/
|
||||
export function hasStoreKey(): boolean {
|
||||
const k = process.env.VAULT_STORE_KEY;
|
||||
return Boolean(k && k.length >= 16);
|
||||
}
|
||||
Reference in New Issue
Block a user