Files
officer a4cf939be7 bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision
rather than a mechanical move.

The wallet permission was kind: 'admin' — owner only, not grantable, invisible
to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts
forces it and the manifest has no field for anything else, deliberately, so
that "can a plugin escalate itself" is a question which cannot be asked. So
extraction could not preserve admin, and that was raised as a blocker before
any code moved.

It was the right change anyway, and the objection inverts. Every wallet query
is scoped by user_id, and that id comes from X-Officer-User which the platform
injects from the authenticated session — a caller cannot name someone else's.
So granting this gives an account ITS OWN wallet. Not through the API and not
with root either does it hand over anyone else's, because their seed is sealed
under a passphrase the server never stores. kind: 'admin' was single-user
thinking; grantable is how a second person gets a wallet at all.

Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically,
and the appName drives the route, the API prefix, the table prefix and the
install key together. Tables renamed to bitcoin_wallet_* accordingly — the
prefix guard normalises the hyphen. Free only because no wallet table existed
on any server, which was checked before deciding: db:push treats a rename as a
drop plus a create.

The three custody test files moved with it and still run — 72 tests over
scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened
on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by
nine, so that trap was already closed.

68 palette classes converted, five status vocabularies hoisted into shared.ts.
Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all
read as warning, and the bitcoin and lightning rails no longer differ by hue.

Deferred on purpose: watch-only data (xpubs, balances, history) stays
unencrypted so a locked wallet still shows a balance. Anyone with the database
can therefore SEE any user's balances; they can never move a coin.
2026-08-19 05:18:29 +00:00

517 lines
21 KiB
TypeScript

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