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:
2026-07-31 06:48:06 +00:00
co-authored by Claude Opus 5
parent 5ee56e736b
commit f8826e4c24
69 changed files with 11867 additions and 0 deletions
+516
View File
@@ -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();
}