SkateSpots/src/lib/bolt11.ts
SkateSpots e7c6bb1ece feat(receipts): verifiable Lightning preimage receipts (money legs)
- getPaymentHashFromBolt11 (bolt11.ts, dependency-free; matches the BOLT11 spec
  test vector) + verifyPreimage (sha256(preimage) === payment_hash).
- usePayPaymentPointer captures the preimage from NWC/WebLN/melt and returns a
  PaymentReceipt; PayWithSats.onPaid carries it; stored device-locally so the
  later confirm step attaches it; publishTrade writes
  ['proof','lnpreimage',preimage,hash] (and ['proof','deposit',...] for barter).
- useTradeReputation verifies and counts it in paymentVerified.
- Honest scope: proves PAYMENT, not delivery; money legs only - a pure hardware
  swap has no invoice, stays attested; a deposit receipt gives barter real cost.
2026-10-01 17:25:16 +07:00

117 lines
4 KiB
TypeScript

/**
* Minimal, dependency-free BOLT11 Lightning invoice amount decoder.
*
* WHY THIS EXISTS
* ---------------
* nostr-tools removed `nip57.getSatoshisAmountFromBolt11` in a newer minor, so
* we decode the amount ourselves. The amount is encoded in the invoice's
* human-readable prefix (HRP), NOT the bech32 data — so we don't need a full
* bech32 decode, just to parse the leading `ln<network><amount><multiplier>`.
*
* BOLT11 amount format (from the spec):
* - HRP looks like: lnbc<amount?><multiplier?>1<data...>
* - <amount> is an integer; <multiplier> is one of:
* m (milli) = 0.001 BTC
* u (micro) = 0.000001 BTC
* n (nano) = 0.000000001 BTC
* p (pico) = 0.000000000001 BTC
* - No amount at all → a "donation" invoice (0 / unspecified).
*
* 1 BTC = 100_000_000 sats.
*/
const MULTIPLIERS: Record<string, number> = {
m: 0.001,
u: 0.000001,
n: 0.000000001,
p: 0.000000000001,
};
const SATS_PER_BTC = 100_000_000;
/**
* Decode the amount (in whole satoshis) from a BOLT11 invoice string.
* Returns 0 for amountless invoices. Throws on a clearly malformed prefix so
* callers can fall back to other amount sources (e.g. the zap `amount` tag).
*/
export function getSatoshisFromBolt11(invoice: string): number {
if (!invoice || typeof invoice !== 'string') {
throw new Error('Invalid BOLT11 invoice');
}
const lower = invoice.trim().toLowerCase();
// The HRP is everything before the bech32 separator '1' that precedes the
// data part. BOLT11 HRPs start with "ln". Grab from the start up to (but not
// including) the final separator between HRP and data. In practice the amount
// block sits right after the network prefix (lnbc / lntb / lntbs / lnbcrt).
const match = lower.match(/^ln(?:bc|tb|tbs|bcrt|sb)(\d+)?([munp])?1/);
if (!match) {
throw new Error('Not a recognizable BOLT11 invoice');
}
const amountStr = match[1];
const multiplier = match[2];
// No amount encoded → amountless invoice.
if (!amountStr) return 0;
const amount = parseInt(amountStr, 10);
if (Number.isNaN(amount)) {
throw new Error('Invalid amount in BOLT11 invoice');
}
// btc value = amount * multiplier (multiplier absent means whole BTC units).
const btc = multiplier ? amount * MULTIPLIERS[multiplier] : amount;
return Math.round(btc * SATS_PER_BTC);
}
/** bech32 charset (the data-part alphabet). */
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
/**
* Extract the **payment hash** (hex) from a BOLT11 invoice — the `p` tagged
* field. Used to verify a Lightning **preimage receipt**: `sha256(preimage)`
* must equal this hash. Dependency-free (we don't verify the bech32 checksum —
* a bad invoice just yields a hash that fails the preimage check).
*/
export function getPaymentHashFromBolt11(invoice: string): string | null {
try {
const lower = invoice.trim().toLowerCase();
const sep = lower.lastIndexOf('1');
if (sep < 1 || sep + 7 > lower.length) return null;
// Data words = everything after the separator, minus the 6-word checksum.
const data = lower.slice(sep + 1, lower.length - 6);
const words = [...data].map((c) => CHARSET.indexOf(c));
if (words.some((w) => w < 0)) return null;
// Tagged fields sit after the 35-bit timestamp (7 five-bit words).
let i = 7;
while (i + 3 <= words.length) {
const tag = words[i];
const len = (words[i + 1] << 5) | words[i + 2];
const start = i + 3;
if (start + len > words.length) break;
if (tag === 1) {
// `p` = payment hash: 32 bytes packed from `len` five-bit words.
const bytes: number[] = [];
let acc = 0;
let bits = 0;
for (let k = start; k < start + len; k += 1) {
acc = (acc << 5) | words[k];
bits += 5;
if (bits >= 8) {
bits -= 8;
bytes.push((acc >>> bits) & 0xff);
}
}
return bytes.slice(0, 32).map((b) => b.toString(16).padStart(2, '0')).join('');
}
i = start + len;
}
return null;
} catch {
return null;
}
}