SkateSpots/src/lib/bolt11.ts
SkateSpots 077fb2d5d1 fix(marketplace): payment, attestation, and escrow safety
Next batch from MARKETPLACE_FLOW_AUDIT.md:
- bolt11.ts: getBolt11Expiry / isBolt11Expired; usePayPaymentPointer refuses
  an expired invoice before paying (a lapsed offer invoice used to fail deep
  in the wallet with a confusing error).
- PayWithSats shows unsupported pointer kinds (creq/clink) as unavailable up
  front instead of enabling a button that fails after the click.
- useTradeAttestations: verify events (onlyVerified) and keep the newest per
  (author, kind, d), so a newer s=revoked supersedes a stale valid copy;
  useTradeReputation no longer trusts unverified relay events.
- EscrowRequestDialog persists the minted token locally before publishing
  (lib/pendingEscrow.ts), so a failed send cannot strand the locked funds.
2026-10-09 23:00:35 +07:00

165 lines
5.6 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;
}
}
/**
* Absolute expiry (unix seconds) of a BOLT11 invoice, or null when it cannot be
* parsed. Uses the `x` tagged field when present, else BOLT11's 3600s default.
*/
export function getBolt11Expiry(invoice: string): number | null {
try {
const lower = invoice.trim().toLowerCase();
const sep = lower.lastIndexOf('1');
if (sep < 1 || sep + 7 > lower.length) return null;
const data = lower.slice(sep + 1, lower.length - 6);
const words = [...data].map((c) => CHARSET.indexOf(c));
if (words.length < 7 || words.slice(0, 7).some((w) => w < 0)) return null;
// 35-bit timestamp = the first 7 five-bit words.
let timestamp = 0;
for (let i = 0; i < 7; i += 1) timestamp = timestamp * 32 + words[i];
let expiry = 3600;
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 === 6) {
// `x` = expiry, in seconds from the timestamp.
let value = 0;
for (let k = start; k < start + len; k += 1) value = value * 32 + words[k];
expiry = value;
break;
}
i = start + len;
}
return timestamp + expiry;
} catch {
return null;
}
}
/** True when a BOLT11 invoice is expired. Unparseable invoices return false. */
export function isBolt11Expired(
invoice: string,
nowSec: number = Math.floor(Date.now() / 1000),
): boolean {
const expiry = getBolt11Expiry(invoice);
return expiry !== null && nowSec > expiry;
}