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.
165 lines
5.6 KiB
TypeScript
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;
|
|
}
|