add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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();
|
||||
}
|
||||
Reference in New Issue
Block a user