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