@getalby/lightning-tools 8.1.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ const BIP21_SCHEME = /^bitcoin:/i;
4
+ // BIP21 grammar: amountparam = "amount=" *digit [ "." *digit ]
5
+ // We require at least one digit on either side of the decimal point — this is
6
+ // slightly stricter than the spec ABNF but matches every real-world example
7
+ // (the spec shows "50", "50.00", "20.3"). Crucially this rejects scientific
8
+ // notation ("1e-3"), hex ("0x10"), commas, signs, and leading "+" / "-".
9
+ const BIP21_AMOUNT_RE = /^(\d+)(?:\.(\d+))?$/;
10
+ const SATS_PER_BTC = 100000000n;
11
+ const BTC_DECIMALS = 8;
12
+ /**
13
+ * Convert a BIP21-compliant decimal BTC string to an integer number of
14
+ * satoshis using exact decimal arithmetic (no floats). Fractional digits
15
+ * beyond 8 are rounded half-up to the nearest satoshi.
16
+ *
17
+ * Assumes the input has already been validated against BIP21_AMOUNT_RE.
18
+ */
19
+ const btcStringToSats = (btc) => {
20
+ const match = BIP21_AMOUNT_RE.exec(btc);
21
+ if (!match) {
22
+ // Should be unreachable — caller pre-validates.
23
+ return Number.NaN;
24
+ }
25
+ const [, integerPart, rawFractional = ""] = match;
26
+ let fractionalSats;
27
+ if (rawFractional.length <= BTC_DECIMALS) {
28
+ fractionalSats = BigInt(rawFractional.padEnd(BTC_DECIMALS, "0"));
29
+ }
30
+ else {
31
+ // Round half-up at the satoshi boundary.
32
+ const truncated = rawFractional.slice(0, BTC_DECIMALS);
33
+ const roundDigit = rawFractional.charCodeAt(BTC_DECIMALS) - 48;
34
+ fractionalSats = BigInt(truncated);
35
+ if (roundDigit >= 5)
36
+ fractionalSats += 1n;
37
+ }
38
+ const totalSats = BigInt(integerPart) * SATS_PER_BTC + fractionalSats;
39
+ return Number(totalSats);
40
+ };
41
+ /**
42
+ * Parse a BIP21 (`bitcoin:`) URI. Returns `null` if the input doesn't have the
43
+ * `bitcoin:` scheme.
44
+ *
45
+ * The address is returned as-is (case preserved) — callers should validate it
46
+ * separately if they need to ensure it's a well-formed bitcoin address.
47
+ *
48
+ * Per BIP21, parameters prefixed with `req-` are required: if the client
49
+ * doesn't understand any of them, the payment MUST NOT be made. The unknown
50
+ * required params are surfaced via `unknownRequiredParams` so callers can
51
+ * decide how to fail.
52
+ *
53
+ * @example
54
+ * parseBip21("bitcoin:bc1q...?amount=0.001&lightning=lnbc...")
55
+ * // => { address: "bc1q...", amount: 0.001, amountSats: 100000, lightning: "lnbc...", ... }
56
+ */
57
+ const parseBip21 = (uri) => {
58
+ if (typeof uri !== "string") {
59
+ return null;
60
+ }
61
+ const normalized = uri.trim();
62
+ if (!BIP21_SCHEME.test(normalized)) {
63
+ return null;
64
+ }
65
+ const withoutScheme = normalized.replace(BIP21_SCHEME, "");
66
+ const queryStart = withoutScheme.indexOf("?");
67
+ const address = queryStart === -1 ? withoutScheme : withoutScheme.slice(0, queryStart);
68
+ const query = queryStart === -1 ? "" : withoutScheme.slice(queryStart + 1);
69
+ const params = {};
70
+ const unknownRequiredParams = [];
71
+ if (query) {
72
+ // URLSearchParams handles decoding and repeated params; for BIP21 last-write-wins
73
+ // is fine since the spec doesn't allow repeats.
74
+ const search = new URLSearchParams(query);
75
+ search.forEach((value, key) => {
76
+ params[key] = value;
77
+ });
78
+ }
79
+ const knownParams = new Set([
80
+ "amount",
81
+ "label",
82
+ "message",
83
+ "lightning",
84
+ "lno",
85
+ ]);
86
+ for (const key of Object.keys(params)) {
87
+ if (key.startsWith("req-") && !knownParams.has(key.slice(4))) {
88
+ unknownRequiredParams.push(key);
89
+ }
90
+ }
91
+ const result = {
92
+ address: address.trim(),
93
+ params,
94
+ unknownRequiredParams,
95
+ };
96
+ if (params.amount !== undefined && BIP21_AMOUNT_RE.test(params.amount)) {
97
+ result.amount = Number(params.amount);
98
+ result.amountSats = btcStringToSats(params.amount);
99
+ }
100
+ if (params.label !== undefined)
101
+ result.label = params.label;
102
+ if (params.message !== undefined)
103
+ result.message = params.message;
104
+ if (params.lightning !== undefined)
105
+ result.lightning = params.lightning;
106
+ if (params.lno !== undefined)
107
+ result.lno = params.lno;
108
+ return result;
109
+ };
110
+ /** Returns true if the input starts with the `bitcoin:` URI scheme. */
111
+ const isBip21 = (uri) => typeof uri === "string" && BIP21_SCHEME.test(uri.trim());
112
+
113
+ exports.isBip21 = isBip21;
114
+ exports.parseBip21 = parseBip21;
115
+ //# sourceMappingURL=bip21.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bip21.cjs","sources":["../../src/bip21/utils.ts"],"sourcesContent":["import type { Bip21 } from \"./types\";\n\nconst BIP21_SCHEME = /^bitcoin:/i;\n\n// BIP21 grammar: amountparam = \"amount=\" *digit [ \".\" *digit ]\n// We require at least one digit on either side of the decimal point — this is\n// slightly stricter than the spec ABNF but matches every real-world example\n// (the spec shows \"50\", \"50.00\", \"20.3\"). Crucially this rejects scientific\n// notation (\"1e-3\"), hex (\"0x10\"), commas, signs, and leading \"+\" / \"-\".\nconst BIP21_AMOUNT_RE = /^(\\d+)(?:\\.(\\d+))?$/;\n\nconst SATS_PER_BTC = 100_000_000n;\nconst BTC_DECIMALS = 8;\n\n/**\n * Convert a BIP21-compliant decimal BTC string to an integer number of\n * satoshis using exact decimal arithmetic (no floats). Fractional digits\n * beyond 8 are rounded half-up to the nearest satoshi.\n *\n * Assumes the input has already been validated against BIP21_AMOUNT_RE.\n */\nconst btcStringToSats = (btc: string): number => {\n const match = BIP21_AMOUNT_RE.exec(btc);\n if (!match) {\n // Should be unreachable — caller pre-validates.\n return Number.NaN;\n }\n const [, integerPart, rawFractional = \"\"] = match;\n\n let fractionalSats: bigint;\n if (rawFractional.length <= BTC_DECIMALS) {\n fractionalSats = BigInt(rawFractional.padEnd(BTC_DECIMALS, \"0\"));\n } else {\n // Round half-up at the satoshi boundary.\n const truncated = rawFractional.slice(0, BTC_DECIMALS);\n const roundDigit = rawFractional.charCodeAt(BTC_DECIMALS) - 48;\n fractionalSats = BigInt(truncated);\n if (roundDigit >= 5) fractionalSats += 1n;\n }\n\n const totalSats = BigInt(integerPart) * SATS_PER_BTC + fractionalSats;\n return Number(totalSats);\n};\n\n/**\n * Parse a BIP21 (`bitcoin:`) URI. Returns `null` if the input doesn't have the\n * `bitcoin:` scheme.\n *\n * The address is returned as-is (case preserved) — callers should validate it\n * separately if they need to ensure it's a well-formed bitcoin address.\n *\n * Per BIP21, parameters prefixed with `req-` are required: if the client\n * doesn't understand any of them, the payment MUST NOT be made. The unknown\n * required params are surfaced via `unknownRequiredParams` so callers can\n * decide how to fail.\n *\n * @example\n * parseBip21(\"bitcoin:bc1q...?amount=0.001&lightning=lnbc...\")\n * // => { address: \"bc1q...\", amount: 0.001, amountSats: 100000, lightning: \"lnbc...\", ... }\n */\nexport const parseBip21 = (uri: string): Bip21 | null => {\n if (typeof uri !== \"string\") {\n return null;\n }\n const normalized = uri.trim();\n if (!BIP21_SCHEME.test(normalized)) {\n return null;\n }\n\n const withoutScheme = normalized.replace(BIP21_SCHEME, \"\");\n const queryStart = withoutScheme.indexOf(\"?\");\n const address =\n queryStart === -1 ? withoutScheme : withoutScheme.slice(0, queryStart);\n const query = queryStart === -1 ? \"\" : withoutScheme.slice(queryStart + 1);\n\n const params: Record<string, string> = {};\n const unknownRequiredParams: string[] = [];\n\n if (query) {\n // URLSearchParams handles decoding and repeated params; for BIP21 last-write-wins\n // is fine since the spec doesn't allow repeats.\n const search = new URLSearchParams(query);\n search.forEach((value, key) => {\n params[key] = value;\n });\n }\n\n const knownParams = new Set([\n \"amount\",\n \"label\",\n \"message\",\n \"lightning\",\n \"lno\",\n ]);\n\n for (const key of Object.keys(params)) {\n if (key.startsWith(\"req-\") && !knownParams.has(key.slice(4))) {\n unknownRequiredParams.push(key);\n }\n }\n\n const result: Bip21 = {\n address: address.trim(),\n params,\n unknownRequiredParams,\n };\n\n if (params.amount !== undefined && BIP21_AMOUNT_RE.test(params.amount)) {\n result.amount = Number(params.amount);\n result.amountSats = btcStringToSats(params.amount);\n }\n if (params.label !== undefined) result.label = params.label;\n if (params.message !== undefined) result.message = params.message;\n if (params.lightning !== undefined) result.lightning = params.lightning;\n if (params.lno !== undefined) result.lno = params.lno;\n\n return result;\n};\n\n/** Returns true if the input starts with the `bitcoin:` URI scheme. */\nexport const isBip21 = (uri: string): boolean =>\n typeof uri === \"string\" && BIP21_SCHEME.test(uri.trim());\n"],"names":[],"mappings":"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"}
@@ -1674,6 +1674,28 @@ class LightningAddress {
1674
1674
  }
1675
1675
  }
1676
1676
 
1677
+ /** Apply a previously-obtained credential to the outgoing request headers. */
1678
+ const applyCredentials = (headers, credentials) => {
1679
+ headers.set(credentials.header, credentials.value);
1680
+ };
1681
+ /** Attach payment metadata to a response and return it (typed). */
1682
+ const attachPayment = (response, payment) => {
1683
+ if (payment) {
1684
+ response.payment = payment;
1685
+ }
1686
+ return response;
1687
+ };
1688
+ /** Payment metadata describing a request authorized with a reused credential. */
1689
+ const reusedCredentialPayment = (credentials) => credentials ? { paid: false, amount: 0, credentials } : undefined;
1690
+ /** Satoshi amount of a BOLT11 invoice (0 when it cannot be decoded). */
1691
+ const getInvoiceAmount = (invoice) => {
1692
+ try {
1693
+ return new Invoice({ pr: invoice }).satoshi;
1694
+ }
1695
+ catch (_) {
1696
+ return 0;
1697
+ }
1698
+ };
1677
1699
  function createGuardedWallet(wallet, maxAmountSats) {
1678
1700
  return {
1679
1701
  payInvoice: async (args) => {
@@ -1725,6 +1747,9 @@ const handleL402Payment = async (l402Header, url, fetchArgs, headers, wallet) =>
1725
1747
  const details = parseL402(l402Header);
1726
1748
  const token = details.token || details.macaroon;
1727
1749
  const invoice = details.invoice;
1750
+ // Preserve the scheme the server challenged with (L402 or LSAT) so the
1751
+ // retry's Authorization header matches what the server expects.
1752
+ const scheme = /^\s*LSAT\b/i.test(l402Header) ? "LSAT" : "L402";
1728
1753
  if (!token) {
1729
1754
  throw new Error("L402: missing token/macaroon in WWW-Authenticate header");
1730
1755
  }
@@ -1732,8 +1757,16 @@ const handleL402Payment = async (l402Header, url, fetchArgs, headers, wallet) =>
1732
1757
  throw new Error("L402: missing invoice in WWW-Authenticate header");
1733
1758
  }
1734
1759
  const invResp = await wallet.payInvoice({ invoice });
1735
- headers.set("Authorization", `L402 ${token}:${invResp.preimage}`);
1736
- return fetch(url, fetchArgs);
1760
+ const value = `${scheme} ${token}:${invResp.preimage}`;
1761
+ headers.set("Authorization", value);
1762
+ const response = await fetch(url, fetchArgs);
1763
+ return attachPayment(response, {
1764
+ paid: true,
1765
+ amount: getInvoiceAmount(invoice),
1766
+ feesPaid: invResp.fees_paid,
1767
+ preimage: invResp.preimage,
1768
+ credentials: { header: "Authorization", value },
1769
+ });
1737
1770
  };
1738
1771
  const fetchWithL402 = async (url, fetchArgs, options) => {
1739
1772
  const wallet = options.wallet;
@@ -1747,6 +1780,15 @@ const fetchWithL402 = async (url, fetchArgs, options) => {
1747
1780
  fetchArgs.mode = "cors";
1748
1781
  const headers = new Headers(fetchArgs.headers ?? undefined);
1749
1782
  fetchArgs.headers = headers;
1783
+ // If the caller supplied a credential, we MUST use it and never pay again —
1784
+ // even if the server still responds with a 402. Re-paying here is the exact
1785
+ // double-charge this API exists to prevent; the caller decides what to do
1786
+ // with a rejected credential (retry after settlement, top up, etc.).
1787
+ if (options.credentials) {
1788
+ applyCredentials(headers, options.credentials);
1789
+ const reusedResp = await fetch(url, fetchArgs);
1790
+ return attachPayment(reusedResp, reusedCredentialPayment(options.credentials));
1791
+ }
1750
1792
  const initResp = await fetch(url, fetchArgs);
1751
1793
  const header = initResp.headers.get("www-authenticate");
1752
1794
  if (!header) {
@@ -1767,7 +1809,7 @@ const buildX402PaymentSignature = (scheme, network, invoice, requirements) => {
1767
1809
  return btoa(unescape(encodeURIComponent(json)));
1768
1810
  };
1769
1811
 
1770
- const handleX402Payment = async (x402Header, url, fetchArgs, headers, wallet) => {
1812
+ const decodeX402Header = (x402Header) => {
1771
1813
  let parsed;
1772
1814
  try {
1773
1815
  parsed = JSON.parse(decodeURIComponent(escape(atob(x402Header))));
@@ -1778,9 +1820,31 @@ const handleX402Payment = async (x402Header, url, fetchArgs, headers, wallet) =>
1778
1820
  if (!Array.isArray(parsed.accepts) || parsed.accepts.length === 0) {
1779
1821
  throw new Error("x402: PAYMENT-REQUIRED header contains no payment options");
1780
1822
  }
1781
- const requirements = parsed.accepts.find((e) => {
1782
- return e.extra?.paymentMethod === "lightning";
1783
- });
1823
+ return { accepts: parsed.accepts };
1824
+ };
1825
+ /**
1826
+ * Probe a PAYMENT-REQUIRED header for a lightning-payable offer without
1827
+ * throwing. Returns the matching requirements, or null if the header has no
1828
+ * lightning entry (e.g. USDC-only endpoints) or is malformed. Used by the
1829
+ * top-level fetch402 dispatcher to decide whether to attempt payment or hand
1830
+ * the 402 back to the caller.
1831
+ */
1832
+ const findX402LightningRequirements = (x402Header) => {
1833
+ let accepts;
1834
+ try {
1835
+ ({ accepts } = decodeX402Header(x402Header));
1836
+ }
1837
+ catch (_) {
1838
+ return null;
1839
+ }
1840
+ const requirements = accepts.find((e) => e?.extra?.paymentMethod === "lightning");
1841
+ if (!requirements?.extra?.invoice)
1842
+ return null;
1843
+ return requirements;
1844
+ };
1845
+ const handleX402Payment = async (x402Header, url, fetchArgs, headers, wallet) => {
1846
+ const { accepts } = decodeX402Header(x402Header);
1847
+ const requirements = accepts.find((e) => e?.extra?.paymentMethod === "lightning");
1784
1848
  if (!requirements) {
1785
1849
  throw new Error("x402: unsupported x402 network, only Bitcoin lightning network is supported.");
1786
1850
  }
@@ -1791,9 +1855,17 @@ const handleX402Payment = async (x402Header, url, fetchArgs, headers, wallet) =>
1791
1855
  if (invoice.amountRaw != requirements.amount) {
1792
1856
  throw new Error(`Invalid invoice amount: ${invoice.amountRaw}. expected ${requirements.amount}`);
1793
1857
  }
1794
- await wallet.payInvoice({ invoice: invoice.paymentRequest });
1795
- headers.set("payment-signature", buildX402PaymentSignature(requirements.scheme, requirements.network, invoice.paymentRequest, requirements));
1796
- return fetch(url, fetchArgs);
1858
+ const invResp = await wallet.payInvoice({ invoice: invoice.paymentRequest });
1859
+ const value = buildX402PaymentSignature(requirements.scheme, requirements.network, invoice.paymentRequest, requirements);
1860
+ headers.set("payment-signature", value);
1861
+ const response = await fetch(url, fetchArgs);
1862
+ return attachPayment(response, {
1863
+ paid: true,
1864
+ amount: invoice.satoshi,
1865
+ feesPaid: invResp.fees_paid,
1866
+ preimage: invResp.preimage,
1867
+ credentials: { header: "payment-signature", value },
1868
+ });
1797
1869
  };
1798
1870
  const fetchWithX402 = async (url, fetchArgs, options) => {
1799
1871
  const wallet = options.wallet;
@@ -1804,6 +1876,15 @@ const fetchWithX402 = async (url, fetchArgs, options) => {
1804
1876
  fetchArgs.mode = "cors";
1805
1877
  const headers = new Headers(fetchArgs.headers ?? undefined);
1806
1878
  fetchArgs.headers = headers;
1879
+ // If the caller supplied a credential, we MUST use it and never pay again —
1880
+ // even if the server still responds with a 402. Re-paying here is the exact
1881
+ // double-charge this API exists to prevent; the caller decides what to do
1882
+ // with a rejected credential (retry after settlement, top up, etc.).
1883
+ if (options.credentials) {
1884
+ applyCredentials(headers, options.credentials);
1885
+ const reusedResp = await fetch(url, fetchArgs);
1886
+ return attachPayment(reusedResp, reusedCredentialPayment(options.credentials));
1887
+ }
1807
1888
  const initResp = await fetch(url, fetchArgs);
1808
1889
  const header = initResp.headers.get("PAYMENT-REQUIRED");
1809
1890
  if (!header) {
@@ -1950,8 +2031,16 @@ const handleMppChargePayment = async (wwwAuthHeader, url, fetchArgs, headers, wa
1950
2031
  const invResp = await wallet.payInvoice({ invoice });
1951
2032
  // Per spec: Authorization: Payment <base64url-token> (single token, no wrapper)
1952
2033
  const credential = buildMppCredential(challenge, invResp.preimage);
1953
- headers.set("Authorization", `Payment ${credential}`);
1954
- return fetch(url, fetchArgs);
2034
+ const value = `Payment ${credential}`;
2035
+ headers.set("Authorization", value);
2036
+ const response = await fetch(url, fetchArgs);
2037
+ return attachPayment(response, {
2038
+ paid: true,
2039
+ amount: getInvoiceAmount(invoice),
2040
+ feesPaid: invResp.fees_paid,
2041
+ preimage: invResp.preimage,
2042
+ credentials: { header: "Authorization", value },
2043
+ });
1955
2044
  };
1956
2045
  /**
1957
2046
  * Fetch a resource protected by the draft-lightning-charge-00 payment
@@ -1962,9 +2051,11 @@ const handleMppChargePayment = async (wwwAuthHeader, url, fetchArgs, headers, wa
1962
2051
  * the function pays the embedded BOLT11 invoice and retries with the
1963
2052
  * resulting preimage as the credential.
1964
2053
  *
1965
- * Note: lightning-charge uses consume-once challenge semantics each
1966
- * challenge embeds a fresh invoice, so paid credentials cannot be reused.
1967
- * The `store` option is accepted for API consistency but is not used.
2054
+ * Pass a previous credential via `options.credentials` to reuse it (e.g. when
2055
+ * polling); the credential is applied and the function NEVER pays again, even
2056
+ * if the server still responds with a 402 (that response is returned as-is).
2057
+ * Note: lightning-charge typically uses consume-once challenge semantics, so a
2058
+ * reused credential is only accepted by servers that explicitly support it.
1968
2059
  */
1969
2060
  const fetchWithMpp = async (url, fetchArgs, options) => {
1970
2061
  const wallet = options.wallet;
@@ -1978,6 +2069,15 @@ const fetchWithMpp = async (url, fetchArgs, options) => {
1978
2069
  fetchArgs.mode = "cors";
1979
2070
  const headers = new Headers(fetchArgs.headers ?? undefined);
1980
2071
  fetchArgs.headers = headers;
2072
+ // If the caller supplied a credential, we MUST use it and never pay again —
2073
+ // even if the server still responds with a 402. Re-paying here is the exact
2074
+ // double-charge this API exists to prevent; the caller decides what to do
2075
+ // with a rejected credential (retry after settlement, top up, etc.).
2076
+ if (options.credentials) {
2077
+ applyCredentials(headers, options.credentials);
2078
+ const reusedResp = await fetch(url, fetchArgs);
2079
+ return attachPayment(reusedResp, reusedCredentialPayment(options.credentials));
2080
+ }
1981
2081
  const initResp = await fetch(url, fetchArgs);
1982
2082
  const wwwAuthHeader = initResp.headers.get("www-authenticate");
1983
2083
  if (!wwwAuthHeader ||
@@ -1998,20 +2098,38 @@ const fetch402 = async (url, fetchArgs, options) => {
1998
2098
  fetchArgs.mode = "cors";
1999
2099
  const headers = new Headers(fetchArgs.headers ?? undefined);
2000
2100
  fetchArgs.headers = headers;
2101
+ // If the caller supplied a credential, we MUST use it and never pay again —
2102
+ // even if the server still responds with a 402. Re-paying here is the exact
2103
+ // double-charge this API exists to prevent; the caller decides what to do
2104
+ // with a rejected credential (retry after settlement, top up, etc.).
2105
+ if (options.credentials) {
2106
+ applyCredentials(headers, options.credentials);
2107
+ const reusedResp = await fetch(url, fetchArgs);
2108
+ return attachPayment(reusedResp, reusedCredentialPayment(options.credentials));
2109
+ }
2001
2110
  const initResp = await fetch(url, fetchArgs);
2111
+ // L402 / LSAT: dedicated scheme, dispatch directly.
2002
2112
  const wwwAuthHeader = initResp.headers.get("www-authenticate");
2003
2113
  if (wwwAuthHeader) {
2004
2114
  const trimmed = wwwAuthHeader.trimStart().toLowerCase();
2005
- if (trimmed.startsWith("payment")) {
2006
- return handleMppChargePayment(wwwAuthHeader, url, fetchArgs, headers, wallet);
2007
- }
2008
2115
  if (trimmed.startsWith("l402") || trimmed.startsWith("lsat")) {
2009
2116
  return handleL402Payment(wwwAuthHeader, url, fetchArgs, headers, wallet);
2010
2117
  }
2011
- throw new Error(`fetch402: unsupported WWW-Authenticate scheme: ${wwwAuthHeader}`);
2012
2118
  }
2119
+ // A server may advertise multiple payment options at once (e.g. an MPP
2120
+ // USDC challenge in WWW-Authenticate alongside an x402 PAYMENT-REQUIRED
2121
+ // header that lists both USDC and lightning). Try each lightning-payable
2122
+ // handler in turn; only if none matches do we hand the original 402 back
2123
+ // to the caller so they can decide what to do with non-lightning offers.
2124
+ // 1. MPP-lightning challenge (Payment method="lightning" intent="charge").
2125
+ // parseMppChallenge returns null for any other method, which lets us
2126
+ // fall through to x402 instead of throwing.
2127
+ if (wwwAuthHeader && parseMppChallenge(wwwAuthHeader)) {
2128
+ return handleMppChargePayment(wwwAuthHeader, url, fetchArgs, headers, wallet);
2129
+ }
2130
+ // 2. x402 PAYMENT-REQUIRED with a lightning entry in `accepts`.
2013
2131
  const x402Header = initResp.headers.get("PAYMENT-REQUIRED");
2014
- if (x402Header) {
2132
+ if (x402Header && findX402LightningRequirements(x402Header)) {
2015
2133
  return handleX402Payment(x402Header, url, fetchArgs, headers, wallet);
2016
2134
  }
2017
2135
  return initResp;
@@ -2145,16 +2263,129 @@ const getFormattedFiatValue = async ({ satoshi, currency, locale, }) => {
2145
2263
  });
2146
2264
  };
2147
2265
 
2266
+ const BIP21_SCHEME = /^bitcoin:/i;
2267
+ // BIP21 grammar: amountparam = "amount=" *digit [ "." *digit ]
2268
+ // We require at least one digit on either side of the decimal point — this is
2269
+ // slightly stricter than the spec ABNF but matches every real-world example
2270
+ // (the spec shows "50", "50.00", "20.3"). Crucially this rejects scientific
2271
+ // notation ("1e-3"), hex ("0x10"), commas, signs, and leading "+" / "-".
2272
+ const BIP21_AMOUNT_RE = /^(\d+)(?:\.(\d+))?$/;
2273
+ const SATS_PER_BTC = 100000000n;
2274
+ const BTC_DECIMALS = 8;
2275
+ /**
2276
+ * Convert a BIP21-compliant decimal BTC string to an integer number of
2277
+ * satoshis using exact decimal arithmetic (no floats). Fractional digits
2278
+ * beyond 8 are rounded half-up to the nearest satoshi.
2279
+ *
2280
+ * Assumes the input has already been validated against BIP21_AMOUNT_RE.
2281
+ */
2282
+ const btcStringToSats = (btc) => {
2283
+ const match = BIP21_AMOUNT_RE.exec(btc);
2284
+ if (!match) {
2285
+ // Should be unreachable — caller pre-validates.
2286
+ return Number.NaN;
2287
+ }
2288
+ const [, integerPart, rawFractional = ""] = match;
2289
+ let fractionalSats;
2290
+ if (rawFractional.length <= BTC_DECIMALS) {
2291
+ fractionalSats = BigInt(rawFractional.padEnd(BTC_DECIMALS, "0"));
2292
+ }
2293
+ else {
2294
+ // Round half-up at the satoshi boundary.
2295
+ const truncated = rawFractional.slice(0, BTC_DECIMALS);
2296
+ const roundDigit = rawFractional.charCodeAt(BTC_DECIMALS) - 48;
2297
+ fractionalSats = BigInt(truncated);
2298
+ if (roundDigit >= 5)
2299
+ fractionalSats += 1n;
2300
+ }
2301
+ const totalSats = BigInt(integerPart) * SATS_PER_BTC + fractionalSats;
2302
+ return Number(totalSats);
2303
+ };
2304
+ /**
2305
+ * Parse a BIP21 (`bitcoin:`) URI. Returns `null` if the input doesn't have the
2306
+ * `bitcoin:` scheme.
2307
+ *
2308
+ * The address is returned as-is (case preserved) — callers should validate it
2309
+ * separately if they need to ensure it's a well-formed bitcoin address.
2310
+ *
2311
+ * Per BIP21, parameters prefixed with `req-` are required: if the client
2312
+ * doesn't understand any of them, the payment MUST NOT be made. The unknown
2313
+ * required params are surfaced via `unknownRequiredParams` so callers can
2314
+ * decide how to fail.
2315
+ *
2316
+ * @example
2317
+ * parseBip21("bitcoin:bc1q...?amount=0.001&lightning=lnbc...")
2318
+ * // => { address: "bc1q...", amount: 0.001, amountSats: 100000, lightning: "lnbc...", ... }
2319
+ */
2320
+ const parseBip21 = (uri) => {
2321
+ if (typeof uri !== "string") {
2322
+ return null;
2323
+ }
2324
+ const normalized = uri.trim();
2325
+ if (!BIP21_SCHEME.test(normalized)) {
2326
+ return null;
2327
+ }
2328
+ const withoutScheme = normalized.replace(BIP21_SCHEME, "");
2329
+ const queryStart = withoutScheme.indexOf("?");
2330
+ const address = queryStart === -1 ? withoutScheme : withoutScheme.slice(0, queryStart);
2331
+ const query = queryStart === -1 ? "" : withoutScheme.slice(queryStart + 1);
2332
+ const params = {};
2333
+ const unknownRequiredParams = [];
2334
+ if (query) {
2335
+ // URLSearchParams handles decoding and repeated params; for BIP21 last-write-wins
2336
+ // is fine since the spec doesn't allow repeats.
2337
+ const search = new URLSearchParams(query);
2338
+ search.forEach((value, key) => {
2339
+ params[key] = value;
2340
+ });
2341
+ }
2342
+ const knownParams = new Set([
2343
+ "amount",
2344
+ "label",
2345
+ "message",
2346
+ "lightning",
2347
+ "lno",
2348
+ ]);
2349
+ for (const key of Object.keys(params)) {
2350
+ if (key.startsWith("req-") && !knownParams.has(key.slice(4))) {
2351
+ unknownRequiredParams.push(key);
2352
+ }
2353
+ }
2354
+ const result = {
2355
+ address: address.trim(),
2356
+ params,
2357
+ unknownRequiredParams,
2358
+ };
2359
+ if (params.amount !== undefined && BIP21_AMOUNT_RE.test(params.amount)) {
2360
+ result.amount = Number(params.amount);
2361
+ result.amountSats = btcStringToSats(params.amount);
2362
+ }
2363
+ if (params.label !== undefined)
2364
+ result.label = params.label;
2365
+ if (params.message !== undefined)
2366
+ result.message = params.message;
2367
+ if (params.lightning !== undefined)
2368
+ result.lightning = params.lightning;
2369
+ if (params.lno !== undefined)
2370
+ result.lno = params.lno;
2371
+ return result;
2372
+ };
2373
+ /** Returns true if the input starts with the `bitcoin:` URI scheme. */
2374
+ const isBip21 = (uri) => typeof uri === "string" && BIP21_SCHEME.test(uri.trim());
2375
+
2148
2376
  exports.DEFAULT_PROXY = DEFAULT_PROXY;
2149
2377
  exports.Invoice = Invoice;
2150
2378
  exports.LN_ADDRESS_REGEX = LN_ADDRESS_REGEX;
2151
2379
  exports.LightningAddress = LightningAddress;
2380
+ exports.applyCredentials = applyCredentials;
2381
+ exports.attachPayment = attachPayment;
2152
2382
  exports.createGuardedWallet = createGuardedWallet;
2153
2383
  exports.decodeInvoice = decodeInvoice;
2154
2384
  exports.fetch402 = fetch402;
2155
2385
  exports.fetchWithL402 = fetchWithL402;
2156
2386
  exports.fetchWithMpp = fetchWithMpp;
2157
2387
  exports.fetchWithX402 = fetchWithX402;
2388
+ exports.findX402LightningRequirements = findX402LightningRequirements;
2158
2389
  exports.fromHexString = fromHexString;
2159
2390
  exports.generateZapEvent = generateZapEvent;
2160
2391
  exports.getEventHash = getEventHash;
@@ -2162,16 +2393,20 @@ exports.getFiatBtcRate = getFiatBtcRate;
2162
2393
  exports.getFiatCurrencies = getFiatCurrencies;
2163
2394
  exports.getFiatValue = getFiatValue;
2164
2395
  exports.getFormattedFiatValue = getFormattedFiatValue;
2396
+ exports.getInvoiceAmount = getInvoiceAmount;
2165
2397
  exports.getSatoshiValue = getSatoshiValue;
2398
+ exports.isBip21 = isBip21;
2166
2399
  exports.isUrl = isUrl;
2167
2400
  exports.isValidAmount = isValidAmount;
2168
2401
  exports.issueL402Macaroon = issueL402Macaroon;
2169
2402
  exports.makeL402AuthenticateHeader = makeL402AuthenticateHeader;
2403
+ exports.parseBip21 = parseBip21;
2170
2404
  exports.parseKeysendResponse = parseKeysendResponse;
2171
2405
  exports.parseL402 = parseL402;
2172
2406
  exports.parseL402Authorization = parseL402Authorization;
2173
2407
  exports.parseLnUrlPayResponse = parseLnUrlPayResponse;
2174
2408
  exports.parseNostrResponse = parseNostrResponse;
2409
+ exports.reusedCredentialPayment = reusedCredentialPayment;
2175
2410
  exports.sendBoostagram = sendBoostagram;
2176
2411
  exports.serializeEvent = serializeEvent;
2177
2412
  exports.validateEvent = validateEvent;