From c0a65cd5e353fe232c3cad59a707f1678313a952 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Tue, 10 Mar 2026 09:50:18 +0100 Subject: [PATCH 01/24] feat: add support for X402 This adds a fetchWithX402() function to consume X402 protected resources that accept bitcoin through the lightning network. --- README.md | 62 ++++++ examples/x402.js | 26 +++ package.json | 5 + rollup.config.js | 1 + src/l402/index.ts | 1 + src/l402/x402.test.ts | 474 ++++++++++++++++++++++++++++++++++++++++++ src/l402/x402.ts | 155 ++++++++++++++ src/x402/index.ts | 3 + 8 files changed, 727 insertions(+) create mode 100644 examples/x402.js create mode 100644 src/l402/x402.test.ts create mode 100644 src/l402/x402.ts create mode 100644 src/x402/index.ts diff --git a/README.md b/README.md index b31c4d9..ba08d18 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,68 @@ await fetchWithL402( ); ``` +### X402 + +Similar to L402 X402 is an open protocol for machine-to-machine payments built on the HTTP 402 Payment Required status code. +It enables APIs and resources to request payments inline, without prior registration or authentication. + +This library includes a `fetchWithX402` function to consume X402-protected resources that support the lightning network. +(Note: X402 works also with other coins and network. This library supports X402 resources that accept Bitcoin on the lightning network) + +#### fetchWithX402(url: string, fetchArgs, options) + +- url: the X402 protected URL +- fetchArgs: arguments are passed to the underlying `fetch()` function used to do the HTTP request +- options: + - wallet: any object that implements `payInvoice(paymentRequest)` or `sendPayment(paymentRequest)` and returns `{ preimage }`. Used to pay the X402 invoice. + - store: a key/value store object to persist the payment proof for each URL. The store must implement a `getItem()`/`setItem()` function as the browser's localStorage. By default a memory storage is used. + +##### Examples + +```js +import { fetchWithX402 } from "@getalby/lightning-tools/x402"; + +// pass a wallet that implements payInvoice() +// the payment proof will be stored in memory and reused for subsequent requests +await fetchWithX402( + "https://x402.albylabs.com/demo/quote", + {}, + { wallet: myWallet, store: window.localStorage }, +) + .then((res) => res.json()) + .then(console.log); +``` + +```js +import { fetchWithX402 } from "@getalby/lightning-tools/x402"; +import { NostrWebLNProvider } from "@getalby/sdk"; + +// use a NWC provider as the wallet to do the payments +const nwc = new NostrWebLNProvider({ + nostrWalletConnectUrl: loadNWCUrl(), +}); + +// this will fetch the resource and pay the invoice using the NWC wallet +await fetchWithX402( + "https://x402.albylabs.com/demo/quote", + {}, + { wallet: nwc }, +) + .then((res) => res.json()) + .then(console.log); +``` + +```js +import { fetchWithX402, NoStorage } from "@getalby/lightning-tools/x402"; + +// do not store the payment proof (pays on every request) +await fetchWithX402( + "https://x402.albylabs.com/demo/quote", + {}, + { wallet: myWallet, store: new NoStorage() }, +); +``` + ### Basic invoice decoding You can initialize an `Invoice` to decode a payment request. diff --git a/examples/x402.js b/examples/x402.js new file mode 100644 index 0000000..39ba890 --- /dev/null +++ b/examples/x402.js @@ -0,0 +1,26 @@ +import { fetchWithX402 } from "@getalby/lightning-tools/l402"; +import { NostrWebLNProvider } from "@getalby/sdk"; +import "websocket-polyfill"; + +const url = "https://x402.albylabs.com/demo/quote"; + +const nostrWalletConnectUrl = process.env.NWC_URL; + +if (!nostrWalletConnectUrl) { + throw new Error("Please set a NWC_URL env variable"); +} + +const nwc = new NostrWebLNProvider({ + nostrWalletConnectUrl, +}); +await nwc.enable(); +nwc.on("payInvoice", (response) => { + console.info(`payment response:`, response); +}); + +fetchWithX402(url, {}, { wallet: nwc }) + .then((response) => response.json()) + .then((data) => { + console.info(data); + nwc.close(); + }); diff --git a/package.json b/package.json index 2c01983..b9e38e4 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,11 @@ "require": "./dist/cjs/l402.cjs", "types": "./dist/types/l402.d.ts" }, + "./x402": { + "import": "./dist/esm/x402.js", + "require": "./dist/cjs/x402.cjs", + "types": "./dist/types/x402.d.ts" + }, "./lnurl": { "import": "./dist/esm/lnurl.js", "require": "./dist/cjs/lnurl.cjs", diff --git a/rollup.config.js b/rollup.config.js index a662d28..e52b885 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -20,6 +20,7 @@ const entries = [ { name: "bolt11", input: "src/bolt11/index.ts" }, { name: "fiat", input: "src/fiat/index.ts" }, { name: "l402", input: "src/l402/index.ts" }, + { name: "x402", input: "src/x402/index.ts" }, { name: "lnurl", input: "src/lnurl/index.ts" }, { name: "podcasting2", input: "src/podcasting2/index.ts" }, ]; diff --git a/src/l402/index.ts b/src/l402/index.ts index 1c6515f..1ab1ba4 100644 --- a/src/l402/index.ts +++ b/src/l402/index.ts @@ -1,2 +1,3 @@ export * from "./l402"; +export * from "./x402"; export * from "./utils"; diff --git a/src/l402/x402.test.ts b/src/l402/x402.test.ts new file mode 100644 index 0000000..e787b24 --- /dev/null +++ b/src/l402/x402.test.ts @@ -0,0 +1,474 @@ +import fetchMock from "jest-fetch-mock"; +import { fetchWithX402 } from "./x402"; +import { MemoryStorage, NoStorage } from "./utils"; + +const INVOICE = + "lnbc100n1pjkse4mpp5q22x8xdwrmpw0t6cww6sey7fn6klnnr5303vj7h44tr3dm2c9y9qdq8f4f5z4qcqzzsxqyz5vqsp5mmhp6cx4xxysc8xvxaj984eue9pm83lxgezmk3umx6wxr9rrq2ns9qyyssqmmrrwthves6z3d85nafj2ds4z20qju2vpaatep8uwrvxz0xs4kznm99m7f6pmkzax09k2k9saldy34z0p0l8gm0zm5xsmg2g667pnlqp7a0qdz"; +const PREIMAGE = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + +const X402_URL = "https://example.com/protected"; + +const REQUIREMENTS = { + scheme: "exact", + network: "lightning:mainnet", + extra: { invoice: INVOICE }, +}; + +// Encode a PAYMENT-REQUIRED header value the same way the server would +function makePaymentRequiredHeader( + requirements = REQUIREMENTS, + accepts = [requirements], +): string { + return btoa(unescape(encodeURIComponent(JSON.stringify({ accepts })))); +} + +// Decode and parse a payment-signature header value back to its object +function parsePaymentSignature(header: string): Record { + return JSON.parse(decodeURIComponent(escape(atob(header)))); +} + +function makeWallet(preimage: string = PREIMAGE) { + return { + payInvoice: jest.fn().mockResolvedValue({ preimage }), + }; +} + +beforeEach(() => { + fetchMock.resetMocks(); +}); + +// --------------------------------------------------------------------------- +// fetchWithX402 +// --------------------------------------------------------------------------- +describe("fetchWithX402", () => { + test("throws when no wallet is provided", async () => { + await expect( + fetchWithX402(X402_URL, {}, { store: new MemoryStorage() }), + ).rejects.toThrow("wallet is missing"); + }); + + test("throws when wallet has no sendPayment or payInvoice", async () => { + await expect( + fetchWithX402( + X402_URL, + {}, + { wallet: {} as never, store: new MemoryStorage() }, + ), + ).rejects.toThrow("wallet must have a sendPayment or payInvoice function"); + }); + + test("returns initial response when no PAYMENT-REQUIRED header", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce(JSON.stringify({ data: "free content" }), { + status: 200, + }); + + const response = await fetchWithX402(X402_URL, {}, { wallet, store }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: "free content" }); + expect(wallet.payInvoice).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("pays invoice and retries fetch on 402 challenge", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ data: "paid content" }), { + status: 200, + }); + + const response = await fetchWithX402(X402_URL, {}, { wallet, store }); + + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); + expect(wallet.payInvoice).toHaveBeenCalledWith(INVOICE); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: "paid content" }); + }); + + test("sets correct payment-signature header on retry", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithX402(X402_URL, {}, { wallet, store }); + + const secondCallInit = fetchMock.mock.calls[1][1] as RequestInit; + const headers = secondCallInit.headers as Record; + const sig = parsePaymentSignature(headers["payment-signature"]); + + expect(sig).toMatchObject({ + x402Version: 2, + scheme: REQUIREMENTS.scheme, + network: REQUIREMENTS.network, + payload: { preimage: PREIMAGE }, + accepted: REQUIREMENTS, + }); + }); + + test("stores payment data after successful payment", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithX402(X402_URL, {}, { wallet, store }); + + const stored = JSON.parse(store.getItem(X402_URL)); + expect(stored).toMatchObject({ + scheme: REQUIREMENTS.scheme, + network: REQUIREMENTS.network, + preimage: PREIMAGE, + requirements: REQUIREMENTS, + }); + }); + + test("uses cached payment data without calling wallet", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + store.setItem( + X402_URL, + JSON.stringify({ + scheme: REQUIREMENTS.scheme, + network: REQUIREMENTS.network, + preimage: PREIMAGE, + requirements: REQUIREMENTS, + }), + ); + + fetchMock.mockResponseOnce(JSON.stringify({ data: "cached access" }), { + status: 200, + }); + + const response = await fetchWithX402(X402_URL, {}, { wallet, store }); + + expect(wallet.payInvoice).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(await response.json()).toEqual({ data: "cached access" }); + + const callInit = fetchMock.mock.calls[0][1] as RequestInit; + const headers = callInit.headers as Record; + const sig = parsePaymentSignature(headers["payment-signature"]); + expect(sig).toMatchObject({ + x402Version: 2, + scheme: REQUIREMENTS.scheme, + network: REQUIREMENTS.network, + payload: { preimage: PREIMAGE }, + }); + }); + + test("second request reuses cached data without re-paying", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ first: true }), { + status: 200, + }); + + await fetchWithX402(X402_URL, {}, { wallet, store }); + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); + + fetchMock.mockResponseOnce(JSON.stringify({ second: true }), { + status: 200, + }); + + const response = await fetchWithX402(X402_URL, {}, { wallet, store }); + + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); // still only 1 + expect(await response.json()).toEqual({ second: true }); + }); + + test("works with NoStorage (never caches, pays every time)", async () => { + const wallet = makeWallet(); + const store = new NoStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ first: true }), { + status: 200, + }); + + await fetchWithX402(X402_URL, {}, { wallet, store }); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ second: true }), { + status: 200, + }); + + await fetchWithX402(X402_URL, {}, { wallet, store }); + + expect(wallet.payInvoice).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + test("falls through on corrupt cache entry", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + store.setItem(X402_URL, "not valid json{{"); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithX402(X402_URL, {}, { wallet, store }); + + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); + }); + + test("falls through on incomplete cache entry (missing preimage)", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + store.setItem( + X402_URL, + JSON.stringify({ + scheme: "exact", + network: "mainnet" /* no preimage, no requirements */, + }), + ); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithX402(X402_URL, {}, { wallet, store }); + + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); + }); + + test("throws on invalid base64 PAYMENT-REQUIRED header", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": "not-valid-base64!!!" }, + }); + + await expect( + fetchWithX402(X402_URL, {}, { wallet, store }), + ).rejects.toThrow("x402: invalid PAYMENT-REQUIRED header"); + }); + + test("throws on valid base64 but non-JSON PAYMENT-REQUIRED header", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": btoa("this is not json") }, + }); + + await expect( + fetchWithX402(X402_URL, {}, { wallet, store }), + ).rejects.toThrow("x402: invalid PAYMENT-REQUIRED header"); + }); + + test("throws when accepts array is empty", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "PAYMENT-REQUIRED": makePaymentRequiredHeader(REQUIREMENTS, []), + }, + }); + + await expect( + fetchWithX402(X402_URL, {}, { wallet, store }), + ).rejects.toThrow( + "x402: PAYMENT-REQUIRED header contains no payment options", + ); + }); + + test("throws when requirements missing invoice", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + const bad = { scheme: "exact", network: "mainnet", extra: {} }; + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader(bad as never) }, + }); + + await expect( + fetchWithX402(X402_URL, {}, { wallet, store }), + ).rejects.toThrow("x402: payment requirements missing invoice"); + }); + + test("throws when requirements missing scheme", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + const bad = { network: "mainnet", extra: { invoice: INVOICE } }; + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader(bad as never) }, + }); + + await expect( + fetchWithX402(X402_URL, {}, { wallet, store }), + ).rejects.toThrow("x402: payment requirements missing scheme or network"); + }); + + test("throws when network is not a lightning network", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + const bad = { + scheme: "exact", + network: "bitcoin:mainnet", + extra: { invoice: INVOICE }, + }; + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader(bad as never) }, + }); + + await expect( + fetchWithX402(X402_URL, {}, { wallet, store }), + ).rejects.toThrow('x402: unsupported network "bitcoin:mainnet"'); + }); + + test("accepts lightning:testnet network", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + const testnet = { + scheme: "exact", + network: "lightning:testnet", + extra: { invoice: INVOICE }, + }; + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { + "PAYMENT-REQUIRED": makePaymentRequiredHeader(testnet as never), + }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await expect( + fetchWithX402(X402_URL, {}, { wallet, store }), + ).resolves.toBeDefined(); + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); + }); + + test("throws when wallet returns no preimage", async () => { + const wallet = { payInvoice: jest.fn().mockResolvedValue({}) }; + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + + await expect( + fetchWithX402(X402_URL, {}, { wallet, store }), + ).rejects.toThrow("x402: wallet did not return a preimage"); + }); + + test("propagates wallet payment errors", async () => { + const wallet = { + payInvoice: jest.fn().mockRejectedValue(new Error("payment failed")), + }; + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + + await expect( + fetchWithX402(X402_URL, {}, { wallet, store }), + ).rejects.toThrow("payment failed"); + }); + + test("works with sendPayment wallet method", async () => { + const wallet = { + sendPayment: jest.fn().mockResolvedValue({ preimage: PREIMAGE }), + }; + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithX402(X402_URL, {}, { wallet, store }); + + expect(wallet.sendPayment).toHaveBeenCalledTimes(1); + expect(wallet.sendPayment).toHaveBeenCalledWith(INVOICE); + }); + + test("sets cache to no-store and mode to cors", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithX402(X402_URL, {}, { wallet, store }); + + const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; + expect(fetchInit.cache).toBe("no-store"); + expect(fetchInit.mode).toBe("cors"); + }); + + test("passes custom fetchArgs through to fetch calls", async () => { + const wallet = makeWallet(); + const store = new MemoryStorage(); + + fetchMock.mockResponseOnce("Payment Required", { + status: 402, + headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); + + await fetchWithX402( + X402_URL, + { method: "POST", headers: { "X-Custom": "value" } }, + { wallet, store }, + ); + + for (const call of fetchMock.mock.calls) { + const fetchInit = call[1] as RequestInit; + const headers = fetchInit.headers as Record; + expect(fetchInit.method).toBe("POST"); + expect(headers["X-Custom"]).toBe("value"); + } + }); +}); diff --git a/src/l402/x402.ts b/src/l402/x402.ts new file mode 100644 index 0000000..3fb2ff0 --- /dev/null +++ b/src/l402/x402.ts @@ -0,0 +1,155 @@ +import { KVStorage, MemoryStorage } from "./utils"; + +const memoryStorage = new MemoryStorage(); + +interface Wallet { + sendPayment?(paymentRequest: string): Promise<{ preimage: string }>; + payInvoice?(paymentRequest: string): Promise<{ preimage: string }>; +} + +interface X402Requirements { + scheme: string; + network: string; + extra: { + invoice: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +const buildPaymentSignature = ( + scheme: string, + network: string, + preimage: string, + requirements: X402Requirements, +): string => { + const json = JSON.stringify({ + x402Version: 2, + scheme, + network, + payload: { preimage }, + accepted: requirements, + }); + // btoa only handles latin1; encode via UTF-8 to be safe + return btoa(unescape(encodeURIComponent(json))); +}; + +export const fetchWithX402 = async ( + url: string, + fetchArgs: RequestInit, + options: { + wallet?: Wallet; + store?: KVStorage; + }, +) => { + if (!options) { + options = {}; + } + const wallet: Wallet | undefined = options.wallet; + if (!wallet) { + throw new Error("wallet is missing"); + } + if (!wallet.sendPayment && !wallet.payInvoice) { + throw new Error("wallet must have a sendPayment or payInvoice function"); + } + const store = options.store || memoryStorage; + if (!fetchArgs) { + fetchArgs = {}; + } + fetchArgs.cache = "no-store"; + fetchArgs.mode = "cors"; + if (!fetchArgs.headers) { + fetchArgs.headers = {}; + } + + const cachedRaw = store.getItem(url); + if (cachedRaw) { + let cached: { + scheme: string; + network: string; + preimage: string; + requirements: X402Requirements; + } | null = null; + try { + cached = JSON.parse(cachedRaw); + } catch (_) { + // corrupt cache entry — fall through to fresh request + store.setItem(url, null as unknown as string); + } + if ( + cached?.scheme && + cached?.network && + cached?.preimage && + cached?.requirements + ) { + fetchArgs.headers["payment-signature"] = buildPaymentSignature( + cached.scheme, + cached.network, + cached.preimage, + cached.requirements, + ); + return await fetch(url, fetchArgs); + } + } + + const initResp = await fetch(url, fetchArgs); + const header = initResp.headers.get("PAYMENT-REQUIRED"); + if (!header) { + return initResp; + } + + let parsed: { accepts?: unknown[] }; + try { + parsed = JSON.parse(decodeURIComponent(escape(atob(header)))); + } catch (_) { + throw new Error( + "x402: invalid PAYMENT-REQUIRED header (not valid base64-encoded JSON)", + ); + } + + if (!Array.isArray(parsed.accepts) || parsed.accepts.length === 0) { + throw new Error( + "x402: PAYMENT-REQUIRED header contains no payment options", + ); + } + + const requirements = parsed.accepts[0] as X402Requirements; + if (!requirements.extra?.invoice) { + throw new Error("x402: payment requirements missing invoice"); + } + if (!requirements.scheme || !requirements.network) { + throw new Error("x402: payment requirements missing scheme or network"); + } + if (!requirements.network.startsWith("lightning")) { + throw new Error( + `x402: unsupported network "${requirements.network}", only lightning networks are supported`, + ); + } + + const invoice = requirements.extra.invoice; + const payFn = + wallet.sendPayment?.bind(wallet) ?? wallet.payInvoice!.bind(wallet); + const invResp = await payFn(invoice); + + if (!invResp?.preimage) { + throw new Error("x402: wallet did not return a preimage"); + } + + store.setItem( + url, + JSON.stringify({ + scheme: requirements.scheme, + network: requirements.network, + preimage: invResp.preimage, + requirements, + }), + ); + + fetchArgs.headers["payment-signature"] = buildPaymentSignature( + requirements.scheme, + requirements.network, + invResp.preimage, + requirements, + ); + return await fetch(url, fetchArgs); +}; diff --git a/src/x402/index.ts b/src/x402/index.ts new file mode 100644 index 0000000..6463c7c --- /dev/null +++ b/src/x402/index.ts @@ -0,0 +1,3 @@ +export * from "../l402/x402"; +export * from "../l402/l402"; +export * from "../l402/utils"; From 42be548ecc3237ce1d59bd013c1b28588d8fd706 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Wed, 11 Mar 2026 18:37:45 +0100 Subject: [PATCH 02/24] chore: cleanup We set the JSON in the storage. Let's assume it's valid and keep the code small. --- src/l402/x402.test.ts | 17 ----------------- src/l402/x402.ts | 7 +------ 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/l402/x402.test.ts b/src/l402/x402.test.ts index e787b24..480edd8 100644 --- a/src/l402/x402.test.ts +++ b/src/l402/x402.test.ts @@ -229,23 +229,6 @@ describe("fetchWithX402", () => { expect(fetchMock).toHaveBeenCalledTimes(4); }); - test("falls through on corrupt cache entry", async () => { - const wallet = makeWallet(); - const store = new MemoryStorage(); - - store.setItem(X402_URL, "not valid json{{"); - - fetchMock.mockResponseOnce("Payment Required", { - status: 402, - headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, - }); - fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); - - await fetchWithX402(X402_URL, {}, { wallet, store }); - - expect(wallet.payInvoice).toHaveBeenCalledTimes(1); - }); - test("falls through on incomplete cache entry (missing preimage)", async () => { const wallet = makeWallet(); const store = new MemoryStorage(); diff --git a/src/l402/x402.ts b/src/l402/x402.ts index 3fb2ff0..f80a8dc 100644 --- a/src/l402/x402.ts +++ b/src/l402/x402.ts @@ -70,12 +70,7 @@ export const fetchWithX402 = async ( preimage: string; requirements: X402Requirements; } | null = null; - try { - cached = JSON.parse(cachedRaw); - } catch (_) { - // corrupt cache entry — fall through to fresh request - store.setItem(url, null as unknown as string); - } + cached = JSON.parse(cachedRaw); if ( cached?.scheme && cached?.network && From 3943ba955868142175e5f0204964c3fb93625112 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Wed, 11 Mar 2026 19:13:20 +0100 Subject: [PATCH 03/24] chore: default to no storage If no storage is provided we assume the payment proofs should not be stored. memory storage does not survive a restart and is also not really helpful actually. --- src/l402/l402.ts | 6 +++--- src/l402/x402.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/l402/l402.ts b/src/l402/l402.ts index 9977650..055510d 100644 --- a/src/l402/l402.ts +++ b/src/l402/l402.ts @@ -1,6 +1,6 @@ -import { KVStorage, MemoryStorage, parseL402 } from "./utils"; +import { KVStorage, NoStorage, parseL402 } from "./utils"; -const memoryStorage = new MemoryStorage(); +const noStorage = new NoStorage(); const HEADER_KEY = "L402"; @@ -25,7 +25,7 @@ export const fetchWithL402 = async ( if (!wallet) { throw new Error("wallet is missing"); } - const store = options.store || memoryStorage; + const store = options.store || noStorage; if (!fetchArgs) { fetchArgs = {}; } diff --git a/src/l402/x402.ts b/src/l402/x402.ts index f80a8dc..2b932cc 100644 --- a/src/l402/x402.ts +++ b/src/l402/x402.ts @@ -1,13 +1,13 @@ -import { KVStorage, MemoryStorage } from "./utils"; +import { KVStorage, NoStorage } from "./utils"; -const memoryStorage = new MemoryStorage(); +const noStorage = new NoStorage(); interface Wallet { sendPayment?(paymentRequest: string): Promise<{ preimage: string }>; payInvoice?(paymentRequest: string): Promise<{ preimage: string }>; } -interface X402Requirements { +export interface X402Requirements { scheme: string; network: string; extra: { @@ -17,7 +17,7 @@ interface X402Requirements { [key: string]: unknown; } -const buildPaymentSignature = ( +export const buildPaymentSignature = ( scheme: string, network: string, preimage: string, @@ -52,7 +52,7 @@ export const fetchWithX402 = async ( if (!wallet.sendPayment && !wallet.payInvoice) { throw new Error("wallet must have a sendPayment or payInvoice function"); } - const store = options.store || memoryStorage; + const store = options.store || noStorage; if (!fetchArgs) { fetchArgs = {}; } From 48148759ddcc5a9c0f7fb8079d56d5f4d1c2ac25 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Thu, 12 Mar 2026 00:39:30 +0100 Subject: [PATCH 04/24] feat: add fetch402 function This function allows to consume L402 and X402 endpoints. It handles both protocols --- README.md | 46 +++++++------ examples/402.js | 23 +++++++ package.json | 5 ++ src/l402/fetch402.ts | 150 +++++++++++++++++++++++++++++++++++++++++++ src/l402/index.ts | 1 + 5 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 examples/402.js create mode 100644 src/l402/fetch402.ts diff --git a/README.md b/README.md index ba08d18..f120ea1 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,6 @@ This library includes a `fetchWithL402` function to consume L402 protected resou - options: - wallet: any object that implements `sendPayment(paymentRequest)` and returns `{ preimage }`. Used to pay the L402 invoice. - store: a key/value store object to persiste the l402 for each URL. The store must implement a `getItem()`/`setItem()` function as the browser's localStorage. By default a memory storage is used. - - headerKey: defaults to L402 but if you need to consume an old LSAT API set this to LSAT ##### Examples @@ -220,17 +219,6 @@ await fetchWithL402( .then(console.log); ``` -```js -import { fetchWithL402, NoStorage } from "@getalby/lightning-tools/l402"; - -// do not store the tokens -await fetchWithL402( - "https://lsat-weather-api.getalby.repl.co/kigali", - {}, - { store: new NoStorage() }, -); -``` - ### X402 Similar to L402 X402 is an open protocol for machine-to-machine payments built on the HTTP 402 Payment Required status code. @@ -282,15 +270,37 @@ await fetchWithX402( .then(console.log); ``` +### fetch402 + +`fetch402` is a single function that transparently handles both L402 and X402 protected resources. Use it when you don't know or don't care which protocol the server uses — it will detect the protocol from the response headers and pay accordingly. + +#### fetch402(url: string, fetchArgs, options) + +- url: the protected URL +- fetchArgs: arguments are passed to the underlying `fetch()` function used to do the HTTP request +- options: + - wallet: any object that implements `sendPayment(paymentRequest)` or `payInvoice({ invoice })` and returns `{ preimage }`. Used to pay L402 and X402 invoices. + - store: a key/value store object to persist the payment proof for each URL. The store must implement a `getItem()`/`setItem()` function as the browser's localStorage. By default a memory storage is used. + +##### Examples + + ```js -import { fetchWithX402, NoStorage } from "@getalby/lightning-tools/x402"; +import { fetch402 } from "@getalby/lightning-tools/l402"; +import { NostrWebLNProvider } from "@getalby/sdk"; -// do not store the payment proof (pays on every request) -await fetchWithX402( - "https://x402.albylabs.com/demo/quote", +const nwc = new NostrWebLNProvider({ + nostrWalletConnectUrl: "nostr+walletconnect://...", +}); + +// use a NWC wallet — works for both L402 and X402 +await fetch402( + "https://example.com/protected-resource", {}, - { wallet: myWallet, store: new NoStorage() }, -); + { wallet: nwc, store: window.localStorage }, +) + .then((res) => res.json()) + .then(console.log); ``` ### Basic invoice decoding diff --git a/examples/402.js b/examples/402.js new file mode 100644 index 0000000..cc8e0d3 --- /dev/null +++ b/examples/402.js @@ -0,0 +1,23 @@ +import { fetch402 } from "@getalby/lightning-tools/402"; +import { NWCClient } from "@getalby/sdk"; + +// fetch402 works with both L402 and X402 endpoints — +// it detects the protocol from the server's response headers automatically. +const url = process.env.URL || "https://x402.albylabs.com/demo/quote"; + +const nostrWalletConnectUrl = process.env.NWC_URL; + +if (!nostrWalletConnectUrl) { + throw new Error("Please set a NWC_URL env variable"); +} + +const nwc = new NWCClient({ + nostrWalletConnectUrl, +}); + +fetch402(url, {}, { wallet: nwc }) + .then((response) => response.json()) + .then((data) => { + console.info(data); + nwc.close(); + }); diff --git a/package.json b/package.json index b9e38e4..d1add80 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,11 @@ "require": "./dist/cjs/l402.cjs", "types": "./dist/types/l402.d.ts" }, + "./402": { + "import": "./dist/esm/l402.js", + "require": "./dist/cjs/l402.cjs", + "types": "./dist/types/l402.d.ts" + }, "./x402": { "import": "./dist/esm/x402.js", "require": "./dist/cjs/x402.cjs", diff --git a/src/l402/fetch402.ts b/src/l402/fetch402.ts new file mode 100644 index 0000000..d9abf5c --- /dev/null +++ b/src/l402/fetch402.ts @@ -0,0 +1,150 @@ +import { KVStorage, NoStorage, parseL402 } from "./utils"; +import { buildPaymentSignature, X402Requirements } from "./x402"; + +const noStorage = new NoStorage(); + +const HEADER_KEY = "L402"; + +interface Wallet { + sendPayment?(paymentRequest: string): Promise<{ preimage: string }>; + payInvoice?(args: { invoice: string }): Promise<{ preimage: string }>; +} + +export const fetch402 = async ( + url: string, + fetchArgs: RequestInit, + options: { + headerKey?: string; + wallet?: Wallet; + store?: KVStorage; + }, +) => { + if (!options) { + options = {}; + } + const headerKey = options.headerKey || HEADER_KEY; + const wallet: Wallet | undefined = options.wallet; + if (!wallet) { + throw new Error("wallet is missing"); + } + if (!wallet.sendPayment && !wallet.payInvoice) { + throw new Error("wallet must have a sendPayment or payInvoice function"); + } + const store = options.store || noStorage; + if (!fetchArgs) { + fetchArgs = {}; + } + fetchArgs.cache = "no-store"; + fetchArgs.mode = "cors"; + if (!fetchArgs.headers) { + fetchArgs.headers = {}; + } + + // Check cache — detect protocol from stored data structure + const cachedRaw = store.getItem(url); + if (cachedRaw) { + const cached = JSON.parse(cachedRaw); + if (cached?.token && cached?.preimage) { + // L402 cached + fetchArgs.headers["Authorization"] = + `${headerKey} ${cached.token}:${cached.preimage}`; + return await fetch(url, fetchArgs); + } + if ( + cached?.scheme && + cached?.network && + cached?.preimage && + cached?.requirements + ) { + // X402 cached + fetchArgs.headers["payment-signature"] = buildPaymentSignature( + cached.scheme, + cached.network, + cached.preimage, + cached.requirements, + ); + return await fetch(url, fetchArgs); + } + } + + // Initial request — advertise L402 support + fetchArgs.headers["Accept-Authenticate"] = headerKey; + const initResp = await fetch(url, fetchArgs); + + const l402Header = initResp.headers.get("www-authenticate"); + if (l402Header) { + const details = parseL402(l402Header); + const token = details.token || details.macaroon; + const inv = details.invoice; + + const invResp = wallet.sendPayment + ? await wallet.sendPayment(inv) + : await wallet.payInvoice!({ invoice: inv }); + + store.setItem(url, JSON.stringify({ token, preimage: invResp.preimage })); + + fetchArgs.headers["Authorization"] = + `${headerKey} ${token}:${invResp.preimage}`; + return await fetch(url, fetchArgs); + } + + const x402Header = initResp.headers.get("PAYMENT-REQUIRED"); + if (x402Header) { + let parsed: { accepts?: unknown[] }; + try { + parsed = JSON.parse(decodeURIComponent(escape(atob(x402Header)))); + } catch (_) { + throw new Error( + "x402: invalid PAYMENT-REQUIRED header (not valid base64-encoded JSON)", + ); + } + + if (!Array.isArray(parsed.accepts) || parsed.accepts.length === 0) { + throw new Error( + "x402: PAYMENT-REQUIRED header contains no payment options", + ); + } + + const requirements = parsed.accepts[0] as X402Requirements; + if (!requirements.extra?.invoice) { + throw new Error("x402: payment requirements missing invoice"); + } + if (!requirements.scheme || !requirements.network) { + throw new Error("x402: payment requirements missing scheme or network"); + } + if (!requirements.network.startsWith("lightning")) { + throw new Error( + `x402: unsupported network "${requirements.network}", only lightning networks are supported`, + ); + } + + const invoice = requirements.extra.invoice; + const invResp = wallet.sendPayment + ? await wallet.sendPayment(invoice) + : await wallet.payInvoice!({ invoice }); + + if (!invResp?.preimage) { + throw new Error("x402: wallet did not return a preimage"); + } + + store.setItem( + url, + JSON.stringify({ + scheme: requirements.scheme, + network: requirements.network, + preimage: invResp.preimage, + requirements, + }), + ); + + fetchArgs.headers["payment-signature"] = buildPaymentSignature( + requirements.scheme, + requirements.network, + invResp.preimage, + requirements, + ); + return await fetch(url, fetchArgs); + } + + return initResp; +}; diff --git a/src/l402/index.ts b/src/l402/index.ts index 1ab1ba4..efcfe5b 100644 --- a/src/l402/index.ts +++ b/src/l402/index.ts @@ -1,3 +1,4 @@ export * from "./l402"; export * from "./x402"; export * from "./utils"; +export * from "./fetch402"; From ed3bbc352319c3fc781669babdad14e68e412560 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 13 Mar 2026 12:32:42 +0100 Subject: [PATCH 05/24] Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f120ea1..87859fc 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ await fetchWithX402( - fetchArgs: arguments are passed to the underlying `fetch()` function used to do the HTTP request - options: - wallet: any object that implements `sendPayment(paymentRequest)` or `payInvoice({ invoice })` and returns `{ preimage }`. Used to pay L402 and X402 invoices. - - store: a key/value store object to persist the payment proof for each URL. The store must implement a `getItem()`/`setItem()` function as the browser's localStorage. By default a memory storage is used. + - store: a key/value store object to persist the payment proof for each URL. The store must implement a `getItem()`/`setItem()` function as the browser's localStorage. By default no storage is used - pass `window.localStorage` or a similar store to enable caching. ##### Examples From 248dcc7be7381db0948aae431e8e678252789cf0 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 14 Mar 2026 11:00:11 +0100 Subject: [PATCH 06/24] chore: cleanup and require NWC compatible wallet interface this makes the implementation easier and webln is not widely used --- src/l402/fetch402.ts | 72 +++++++++++++++----------------------- src/l402/l402.ts | 17 +++------ src/l402/utils.ts | 37 +++++++++++++++++++- src/l402/x402.ts | 82 +++++++++++--------------------------------- 4 files changed, 88 insertions(+), 120 deletions(-) diff --git a/src/l402/fetch402.ts b/src/l402/fetch402.ts index d9abf5c..c8898e6 100644 --- a/src/l402/fetch402.ts +++ b/src/l402/fetch402.ts @@ -1,35 +1,25 @@ -import { KVStorage, NoStorage, parseL402 } from "./utils"; -import { buildPaymentSignature, X402Requirements } from "./x402"; +import { + KVStorage, + NoStorage, + parseL402, + buildX402PaymentSignature, + Wallet, + X402Requirements, +} from "./utils"; const noStorage = new NoStorage(); const HEADER_KEY = "L402"; -interface Wallet { - sendPayment?(paymentRequest: string): Promise<{ preimage: string }>; - payInvoice?(args: { invoice: string }): Promise<{ preimage: string }>; -} - export const fetch402 = async ( url: string, fetchArgs: RequestInit, options: { - headerKey?: string; - wallet?: Wallet; + wallet: Wallet; store?: KVStorage; }, ) => { - if (!options) { - options = {}; - } - const headerKey = options.headerKey || HEADER_KEY; - const wallet: Wallet | undefined = options.wallet; - if (!wallet) { - throw new Error("wallet is missing"); - } - if (!wallet.sendPayment && !wallet.payInvoice) { - throw new Error("wallet must have a sendPayment or payInvoice function"); - } + const wallet = options.wallet; const store = options.store || noStorage; if (!fetchArgs) { fetchArgs = {}; @@ -47,7 +37,7 @@ export const fetch402 = async ( if (cached?.token && cached?.preimage) { // L402 cached fetchArgs.headers["Authorization"] = - `${headerKey} ${cached.token}:${cached.preimage}`; + `${HEADER_KEY} ${cached.token}:${cached.preimage}`; return await fetch(url, fetchArgs); } if ( @@ -57,7 +47,7 @@ export const fetch402 = async ( cached?.requirements ) { // X402 cached - fetchArgs.headers["payment-signature"] = buildPaymentSignature( + fetchArgs.headers["payment-signature"] = buildX402PaymentSignature( cached.scheme, cached.network, cached.preimage, @@ -68,23 +58,22 @@ export const fetch402 = async ( } // Initial request — advertise L402 support - fetchArgs.headers["Accept-Authenticate"] = headerKey; + fetchArgs.headers["Accept-Authenticate"] = HEADER_KEY; const initResp = await fetch(url, fetchArgs); const l402Header = initResp.headers.get("www-authenticate"); if (l402Header) { const details = parseL402(l402Header); const token = details.token || details.macaroon; - const inv = details.invoice; + const invoice = details.invoice; - const invResp = wallet.sendPayment - ? await wallet.sendPayment(inv) - : await wallet.payInvoice!({ invoice: inv }); + const invResp = await wallet.payInvoice!({ invoice }); store.setItem(url, JSON.stringify({ token, preimage: invResp.preimage })); fetchArgs.headers["Authorization"] = - `${headerKey} ${token}:${invResp.preimage}`; + `${HEADER_KEY} ${token}:${invResp.preimage}`; + return await fetch(url, fetchArgs); } @@ -105,27 +94,20 @@ export const fetch402 = async ( ); } - const requirements = parsed.accepts[0] as X402Requirements; - if (!requirements.extra?.invoice) { - throw new Error("x402: payment requirements missing invoice"); - } - if (!requirements.scheme || !requirements.network) { - throw new Error("x402: payment requirements missing scheme or network"); - } - if (!requirements.network.startsWith("lightning")) { + const requirements = (parsed.accepts as X402Requirements[]).find((e) => { + return e.network.startsWith("lightning"); + }); + if (!requirements) { throw new Error( - `x402: unsupported network "${requirements.network}", only lightning networks are supported`, + "x402: unsupported x402 network, only lightning networks are supported", ); } + if (!requirements.extra?.invoice) { + throw new Error("x402: payment requirements missing lightning invoice"); + } const invoice = requirements.extra.invoice; - const invResp = wallet.sendPayment - ? await wallet.sendPayment(invoice) - : await wallet.payInvoice!({ invoice }); - - if (!invResp?.preimage) { - throw new Error("x402: wallet did not return a preimage"); - } + const invResp = await wallet.payInvoice!({ invoice }); store.setItem( url, @@ -137,7 +119,7 @@ export const fetch402 = async ( }), ); - fetchArgs.headers["payment-signature"] = buildPaymentSignature( + fetchArgs.headers["payment-signature"] = buildX402PaymentSignature( requirements.scheme, requirements.network, invResp.preimage, diff --git a/src/l402/l402.ts b/src/l402/l402.ts index 055510d..58a5912 100644 --- a/src/l402/l402.ts +++ b/src/l402/l402.ts @@ -1,27 +1,20 @@ -import { KVStorage, NoStorage, parseL402 } from "./utils"; +import { KVStorage, NoStorage, parseL402, Wallet } from "./utils"; const noStorage = new NoStorage(); const HEADER_KEY = "L402"; -interface Wallet { - sendPayment(paymentRequest: string): Promise<{ preimage: string }>; -} - export const fetchWithL402 = async ( url: string, fetchArgs: RequestInit, options: { + wallet: Wallet; headerKey?: string; - wallet?: Wallet; store?: KVStorage; }, ) => { - if (!options) { - options = {}; - } const headerKey = options.headerKey || HEADER_KEY; - const wallet: Wallet | undefined = options.wallet; + const wallet = options.wallet; if (!wallet) { throw new Error("wallet is missing"); } @@ -51,9 +44,9 @@ export const fetchWithL402 = async ( const details = parseL402(header); const token = details.token || details.macaroon; - const inv = details.invoice; + const invoice = details.invoice; - const invResp = await wallet.sendPayment(inv); + const invResp = await wallet.payInvoice({ invoice }); store.setItem( url, diff --git a/src/l402/utils.ts b/src/l402/utils.ts index 0129c31..9d41a4c 100644 --- a/src/l402/utils.ts +++ b/src/l402/utils.ts @@ -3,6 +3,10 @@ export interface KVStorage { setItem(key: string, value: string): void; } +export interface Wallet { + payInvoice(args: { invoice: string }): Promise<{ preimage: string }>; +} + export class MemoryStorage implements KVStorage { storage; @@ -50,8 +54,39 @@ export const parseL402 = (input: string): Record => { return keyValuePairs; }; -export const makeAuthenticateHeader = (args: { macaroon: string, invoice: string, key?: string }) => { +export const makeL402AuthenticateHeader = (args: { + macaroon: string; + invoice: string; + key?: string; +}) => { const key = args.key || "L402"; return `${key} macaroon="${args.macaroon}", invoice="${args.invoice}"`; +}; + +export interface X402Requirements { + scheme: string; + network: string; + extra: { + invoice: string; + [key: string]: unknown; + }; + [key: string]: unknown; } + +export const buildX402PaymentSignature = ( + scheme: string, + network: string, + preimage: string, + requirements: X402Requirements, +): string => { + const json = JSON.stringify({ + x402Version: 2, + scheme, + network, + payload: { preimage }, + accepted: requirements, + }); + // btoa only handles latin1; encode via UTF-8 to be safe + return btoa(unescape(encodeURIComponent(json))); +}; diff --git a/src/l402/x402.ts b/src/l402/x402.ts index 2b932cc..8fdc56a 100644 --- a/src/l402/x402.ts +++ b/src/l402/x402.ts @@ -1,57 +1,22 @@ -import { KVStorage, NoStorage } from "./utils"; +import { + KVStorage, + NoStorage, + buildX402PaymentSignature, + Wallet, + X402Requirements, +} from "./utils"; const noStorage = new NoStorage(); -interface Wallet { - sendPayment?(paymentRequest: string): Promise<{ preimage: string }>; - payInvoice?(paymentRequest: string): Promise<{ preimage: string }>; -} - -export interface X402Requirements { - scheme: string; - network: string; - extra: { - invoice: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -export const buildPaymentSignature = ( - scheme: string, - network: string, - preimage: string, - requirements: X402Requirements, -): string => { - const json = JSON.stringify({ - x402Version: 2, - scheme, - network, - payload: { preimage }, - accepted: requirements, - }); - // btoa only handles latin1; encode via UTF-8 to be safe - return btoa(unescape(encodeURIComponent(json))); -}; - export const fetchWithX402 = async ( url: string, fetchArgs: RequestInit, options: { - wallet?: Wallet; + wallet: Wallet; store?: KVStorage; }, ) => { - if (!options) { - options = {}; - } - const wallet: Wallet | undefined = options.wallet; - if (!wallet) { - throw new Error("wallet is missing"); - } - if (!wallet.sendPayment && !wallet.payInvoice) { - throw new Error("wallet must have a sendPayment or payInvoice function"); - } + const wallet = options.wallet; const store = options.store || noStorage; if (!fetchArgs) { fetchArgs = {}; @@ -77,7 +42,7 @@ export const fetchWithX402 = async ( cached?.preimage && cached?.requirements ) { - fetchArgs.headers["payment-signature"] = buildPaymentSignature( + fetchArgs.headers["payment-signature"] = buildX402PaymentSignature( cached.scheme, cached.network, cached.preimage, @@ -108,27 +73,20 @@ export const fetchWithX402 = async ( ); } - const requirements = parsed.accepts[0] as X402Requirements; - if (!requirements.extra?.invoice) { - throw new Error("x402: payment requirements missing invoice"); - } - if (!requirements.scheme || !requirements.network) { - throw new Error("x402: payment requirements missing scheme or network"); - } - if (!requirements.network.startsWith("lightning")) { + const requirements = (parsed.accepts as X402Requirements[]).find((e) => { + return e.network.startsWith("lightning"); + }); + if (!requirements) { throw new Error( - `x402: unsupported network "${requirements.network}", only lightning networks are supported`, + "x402: unsupported x402 network, only lightning networks are supported", ); } + if (!requirements.extra?.invoice) { + throw new Error("x402: payment requirements missing lightning invoice"); + } const invoice = requirements.extra.invoice; - const payFn = - wallet.sendPayment?.bind(wallet) ?? wallet.payInvoice!.bind(wallet); - const invResp = await payFn(invoice); - - if (!invResp?.preimage) { - throw new Error("x402: wallet did not return a preimage"); - } + const invResp = await wallet.payInvoice!({ invoice }); store.setItem( url, @@ -140,7 +98,7 @@ export const fetchWithX402 = async ( }), ); - fetchArgs.headers["payment-signature"] = buildPaymentSignature( + fetchArgs.headers["payment-signature"] = buildX402PaymentSignature( requirements.scheme, requirements.network, invResp.preimage, From b3ee2afe63c2008f225a1557cff2cc76cfafaf27 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 14 Mar 2026 11:02:08 +0100 Subject: [PATCH 07/24] test: x402/l402 generated tests --- jest.config.ts | 17 ++--- src/l402/l402.test.ts | 124 ++++++++++++++++---------------- src/l402/x402.test.ts | 162 +++++++++++++++--------------------------- 3 files changed, 127 insertions(+), 176 deletions(-) diff --git a/jest.config.ts b/jest.config.ts index 8ffd480..db54545 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,8 +1,9 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - setupFiles: [ - "./setupJest.ts" - ] -}; \ No newline at end of file +import type { Config } from "ts-jest"; + +const config: Config = { + preset: "ts-jest", + testEnvironment: "node", + setupFiles: ["./setupJest.ts"], +}; + +export default config; \ No newline at end of file diff --git a/src/l402/l402.test.ts b/src/l402/l402.test.ts index 827465a..791399b 100644 --- a/src/l402/l402.test.ts +++ b/src/l402/l402.test.ts @@ -1,6 +1,11 @@ import fetchMock from "jest-fetch-mock"; import { fetchWithL402 } from "./l402"; -import { MemoryStorage, NoStorage, parseL402, makeAuthenticateHeader } from "./utils"; +import { + MemoryStorage, + NoStorage, + parseL402, + makeL402AuthenticateHeader, +} from "./utils"; const MACAROON = "AgEEbHNhdAJCAAAClGOZrh7C569Yc7UMk8merfnMdIviyXr1qscW7VgpChNl21LkZ8Jex5QiPp+E1VaabeJDuWmlrh/j583axFpNAAIXc2VydmljZXM9cmFuZG9tbnVtYmVyOjAAAiZyYW5kb21udW1iZXJfY2FwYWJpbGl0aZVzPWFkZCxzdWJ0cmFjdAAABiAvFpzXGyc+8d/I9nMKKvAYP8w7kUlhuxS0eFN2sqmqHQ=="; @@ -15,7 +20,7 @@ const L402_URL = "https://example.com/protected"; function makeWallet(preimage: string = PREIMAGE) { return { - sendPayment: jest.fn().mockResolvedValue({ preimage }), + payInvoice: jest.fn().mockResolvedValue({ preimage }), }; } @@ -62,18 +67,6 @@ describe("parseL402", () => { // fetchWithL402 // --------------------------------------------------------------------------- describe("fetchWithL402", () => { - test("throws when no wallet is provided", async () => { - await expect( - fetchWithL402(L402_URL, {}, { store: new MemoryStorage() }), - ).rejects.toThrow("wallet is missing"); - }); - - test("throws when wallet is explicitly undefined", async () => { - await expect( - fetchWithL402(L402_URL, {}, { wallet: undefined, store: new MemoryStorage() }), - ).rejects.toThrow("wallet is missing"); - }); - test("returns initial response when no www-authenticate header (non-402)", async () => { const wallet = makeWallet(); const store = new MemoryStorage(); @@ -85,7 +78,7 @@ describe("fetchWithL402", () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ data: "free content" }); - expect(wallet.sendPayment).not.toHaveBeenCalled(); + expect(wallet.payInvoice).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -96,7 +89,12 @@ describe("fetchWithL402", () => { // First fetch: 402 with www-authenticate header fetchMock.mockResponseOnce("Payment Required", { status: 402, - headers: { "www-authenticate": makeAuthenticateHeader({ macaroon: MACAROON, invoice: INVOICE }) }, + headers: { + "www-authenticate": makeL402AuthenticateHeader({ + macaroon: MACAROON, + invoice: INVOICE, + }), + }, }); // Second fetch: success after payment @@ -105,16 +103,14 @@ describe("fetchWithL402", () => { const response = await fetchWithL402(L402_URL, {}, { wallet, store }); - expect(wallet.sendPayment).toHaveBeenCalledTimes(1); - expect(wallet.sendPayment).toHaveBeenCalledWith(INVOICE); + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); + expect(wallet.payInvoice).toHaveBeenCalledWith({ invoice: INVOICE }); expect(fetchMock).toHaveBeenCalledTimes(2); // Verify the second request includes the Authorization header const secondCallInit = fetchMock.mock.calls[1][1] as RequestInit; const secondHeaders = secondCallInit.headers as Record; - expect(secondHeaders["Authorization"]).toBe( - `L402 ${MACAROON}:${PREIMAGE}`, - ); + expect(secondHeaders["Authorization"]).toBe(`L402 ${MACAROON}:${PREIMAGE}`); expect(response.status).toBe(200); expect(await response.json()).toEqual({ data: "paid content" }); @@ -126,7 +122,12 @@ describe("fetchWithL402", () => { fetchMock.mockResponseOnce("Payment Required", { status: 402, - headers: { "www-authenticate": makeAuthenticateHeader({ macaroon: MACAROON, invoice: INVOICE }) }, + headers: { + "www-authenticate": makeL402AuthenticateHeader({ + macaroon: MACAROON, + invoice: INVOICE, + }), + }, }); fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); @@ -154,39 +155,17 @@ describe("fetchWithL402", () => { const response = await fetchWithL402(L402_URL, {}, { wallet, store }); - expect(wallet.sendPayment).not.toHaveBeenCalled(); + expect(wallet.payInvoice).not.toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalledTimes(1); // Verify the Authorization header was set from the cache const callInit = fetchMock.mock.calls[0][1] as RequestInit; const headers = callInit.headers as Record; - expect(headers["Authorization"]).toBe( - `L402 ${MACAROON}:${PREIMAGE}`, - ); + expect(headers["Authorization"]).toBe(`L402 ${MACAROON}:${PREIMAGE}`); expect(await response.json()).toEqual({ data: "cached access" }); }); - test("uses custom LSAT headerKey", async () => { - const wallet = makeWallet(); - const store = new MemoryStorage(); - - fetchMock.mockResponseOnce("Payment Required", { - status: 402, - headers: { "www-authenticate": makeAuthenticateHeader({ macaroon: MACAROON, invoice: INVOICE, key: "LSAT" }) }, - }); - fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); - - await fetchWithL402(L402_URL, {}, { wallet, store, headerKey: "LSAT" }); - - // Verify the second request uses LSAT in the Authorization header - const secondCallInit = fetchMock.mock.calls[1][1] as RequestInit; - const secondHeaders = secondCallInit.headers as Record; - expect(secondHeaders["Authorization"]).toBe( - `LSAT ${MACAROON}:${PREIMAGE}`, - ); - }); - test("works with NoStorage (never caches)", async () => { const wallet = makeWallet(); const store = new NoStorage(); @@ -194,7 +173,12 @@ describe("fetchWithL402", () => { // First request flow fetchMock.mockResponseOnce("Payment Required", { status: 402, - headers: { "www-authenticate": makeAuthenticateHeader({ macaroon: MACAROON, invoice: INVOICE }) }, + headers: { + "www-authenticate": makeL402AuthenticateHeader({ + macaroon: MACAROON, + invoice: INVOICE, + }), + }, }); fetchMock.mockResponseOnce(JSON.stringify({ first: true }), { status: 200, @@ -205,7 +189,12 @@ describe("fetchWithL402", () => { // Second request flow — should NOT use cache since NoStorage always returns null fetchMock.mockResponseOnce("Payment Required", { status: 402, - headers: { "www-authenticate": makeAuthenticateHeader({ macaroon: MACAROON, invoice: INVOICE }) }, + headers: { + "www-authenticate": makeL402AuthenticateHeader({ + macaroon: MACAROON, + invoice: INVOICE, + }), + }, }); fetchMock.mockResponseOnce(JSON.stringify({ second: true }), { status: 200, @@ -213,22 +202,25 @@ describe("fetchWithL402", () => { await fetchWithL402(L402_URL, {}, { wallet, store }); - // wallet.sendPayment should have been called twice (no caching) - expect(wallet.sendPayment).toHaveBeenCalledTimes(2); + // wallet.payInvoice should have been called twice (no caching) + expect(wallet.payInvoice).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(4); }); - test("propagates wallet.sendPayment errors", async () => { + test("propagates wallet.payInvoice errors", async () => { const wallet = { - sendPayment: jest - .fn() - .mockRejectedValue(new Error("payment failed")), + payInvoice: jest.fn().mockRejectedValue(new Error("payment failed")), }; const store = new MemoryStorage(); fetchMock.mockResponseOnce("Payment Required", { status: 402, - headers: { "www-authenticate": makeAuthenticateHeader({ macaroon: MACAROON, invoice: INVOICE }) }, + headers: { + "www-authenticate": makeL402AuthenticateHeader({ + macaroon: MACAROON, + invoice: INVOICE, + }), + }, }); await expect( @@ -243,7 +235,12 @@ describe("fetchWithL402", () => { fetchMock.mockResponseOnce("Payment Required", { status: 402, - headers: { "www-authenticate": makeAuthenticateHeader({ macaroon: MACAROON, invoice: INVOICE }) }, + headers: { + "www-authenticate": makeL402AuthenticateHeader({ + macaroon: MACAROON, + invoice: INVOICE, + }), + }, }); fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); @@ -282,14 +279,19 @@ describe("fetchWithL402", () => { // First request: full L402 handshake fetchMock.mockResponseOnce("Payment Required", { status: 402, - headers: { "www-authenticate": makeAuthenticateHeader({ macaroon: MACAROON, invoice: INVOICE }) }, + headers: { + "www-authenticate": makeL402AuthenticateHeader({ + macaroon: MACAROON, + invoice: INVOICE, + }), + }, }); fetchMock.mockResponseOnce(JSON.stringify({ first: true }), { status: 200, }); await fetchWithL402(L402_URL, {}, { wallet, store }); - expect(wallet.sendPayment).toHaveBeenCalledTimes(1); + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); // Second request: should use cached token, no new payment fetchMock.mockResponseOnce(JSON.stringify({ second: true }), { @@ -298,13 +300,11 @@ describe("fetchWithL402", () => { const response = await fetchWithL402(L402_URL, {}, { wallet, store }); - expect(wallet.sendPayment).toHaveBeenCalledTimes(1); // still only 1 + expect(wallet.payInvoice).toHaveBeenCalledTimes(1); // still only 1 expect(await response.json()).toEqual({ second: true }); const lastCallInit = fetchMock.mock.calls[2][1] as RequestInit; const lastHeaders = lastCallInit.headers as Record; - expect(lastHeaders["Authorization"]).toBe( - `L402 ${MACAROON}:${PREIMAGE}`, - ); + expect(lastHeaders["Authorization"]).toBe(`L402 ${MACAROON}:${PREIMAGE}`); }); }); diff --git a/src/l402/x402.test.ts b/src/l402/x402.test.ts index 480edd8..d02e7da 100644 --- a/src/l402/x402.test.ts +++ b/src/l402/x402.test.ts @@ -15,7 +15,6 @@ const REQUIREMENTS = { extra: { invoice: INVOICE }, }; -// Encode a PAYMENT-REQUIRED header value the same way the server would function makePaymentRequiredHeader( requirements = REQUIREMENTS, accepts = [requirements], @@ -23,7 +22,6 @@ function makePaymentRequiredHeader( return btoa(unescape(encodeURIComponent(JSON.stringify({ accepts })))); } -// Decode and parse a payment-signature header value back to its object function parsePaymentSignature(header: string): Record { return JSON.parse(decodeURIComponent(escape(atob(header)))); } @@ -42,31 +40,14 @@ beforeEach(() => { // fetchWithX402 // --------------------------------------------------------------------------- describe("fetchWithX402", () => { - test("throws when no wallet is provided", async () => { - await expect( - fetchWithX402(X402_URL, {}, { store: new MemoryStorage() }), - ).rejects.toThrow("wallet is missing"); - }); - - test("throws when wallet has no sendPayment or payInvoice", async () => { - await expect( - fetchWithX402( - X402_URL, - {}, - { wallet: {} as never, store: new MemoryStorage() }, - ), - ).rejects.toThrow("wallet must have a sendPayment or payInvoice function"); - }); - test("returns initial response when no PAYMENT-REQUIRED header", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); fetchMock.mockResponseOnce(JSON.stringify({ data: "free content" }), { status: 200, }); - const response = await fetchWithX402(X402_URL, {}, { wallet, store }); + const response = await fetchWithX402(X402_URL, {}, { wallet }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ data: "free content" }); @@ -76,7 +57,6 @@ describe("fetchWithX402", () => { test("pays invoice and retries fetch on 402 challenge", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); fetchMock.mockResponseOnce("Payment Required", { status: 402, @@ -86,10 +66,10 @@ describe("fetchWithX402", () => { status: 200, }); - const response = await fetchWithX402(X402_URL, {}, { wallet, store }); + const response = await fetchWithX402(X402_URL, {}, { wallet }); expect(wallet.payInvoice).toHaveBeenCalledTimes(1); - expect(wallet.payInvoice).toHaveBeenCalledWith(INVOICE); + expect(wallet.payInvoice).toHaveBeenCalledWith({ invoice: INVOICE }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(response.status).toBe(200); expect(await response.json()).toEqual({ data: "paid content" }); @@ -97,7 +77,6 @@ describe("fetchWithX402", () => { test("sets correct payment-signature header on retry", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); fetchMock.mockResponseOnce("Payment Required", { status: 402, @@ -105,7 +84,7 @@ describe("fetchWithX402", () => { }); fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); - await fetchWithX402(X402_URL, {}, { wallet, store }); + await fetchWithX402(X402_URL, {}, { wallet }); const secondCallInit = fetchMock.mock.calls[1][1] as RequestInit; const headers = secondCallInit.headers as Record; @@ -237,7 +216,8 @@ describe("fetchWithX402", () => { X402_URL, JSON.stringify({ scheme: "exact", - network: "mainnet" /* no preimage, no requirements */, + network: "lightning:mainnet", + // no preimage, no requirements }), ); @@ -254,35 +234,32 @@ describe("fetchWithX402", () => { test("throws on invalid base64 PAYMENT-REQUIRED header", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); fetchMock.mockResponseOnce("Payment Required", { status: 402, headers: { "PAYMENT-REQUIRED": "not-valid-base64!!!" }, }); - await expect( - fetchWithX402(X402_URL, {}, { wallet, store }), - ).rejects.toThrow("x402: invalid PAYMENT-REQUIRED header"); + await expect(fetchWithX402(X402_URL, {}, { wallet })).rejects.toThrow( + "x402: invalid PAYMENT-REQUIRED header (not valid base64-encoded JSON)", + ); }); test("throws on valid base64 but non-JSON PAYMENT-REQUIRED header", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); fetchMock.mockResponseOnce("Payment Required", { status: 402, headers: { "PAYMENT-REQUIRED": btoa("this is not json") }, }); - await expect( - fetchWithX402(X402_URL, {}, { wallet, store }), - ).rejects.toThrow("x402: invalid PAYMENT-REQUIRED header"); + await expect(fetchWithX402(X402_URL, {}, { wallet })).rejects.toThrow( + "x402: invalid PAYMENT-REQUIRED header (not valid base64-encoded JSON)", + ); }); test("throws when accepts array is empty", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); fetchMock.mockResponseOnce("Payment Required", { status: 402, @@ -291,65 +268,49 @@ describe("fetchWithX402", () => { }, }); - await expect( - fetchWithX402(X402_URL, {}, { wallet, store }), - ).rejects.toThrow( + await expect(fetchWithX402(X402_URL, {}, { wallet })).rejects.toThrow( "x402: PAYMENT-REQUIRED header contains no payment options", ); }); - test("throws when requirements missing invoice", async () => { + test("throws when no accepted entry has a lightning network", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); - const bad = { scheme: "exact", network: "mainnet", extra: {} }; - - fetchMock.mockResponseOnce("Payment Required", { - status: 402, - headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader(bad as never) }, - }); - - await expect( - fetchWithX402(X402_URL, {}, { wallet, store }), - ).rejects.toThrow("x402: payment requirements missing invoice"); - }); - - test("throws when requirements missing scheme", async () => { - const wallet = makeWallet(); - const store = new MemoryStorage(); - const bad = { network: "mainnet", extra: { invoice: INVOICE } }; + const nonLightning = { + scheme: "exact", + network: "bitcoin:mainnet", + extra: { invoice: INVOICE }, + }; fetchMock.mockResponseOnce("Payment Required", { status: 402, - headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader(bad as never) }, + headers: { + "PAYMENT-REQUIRED": makePaymentRequiredHeader(nonLightning as never, [ + nonLightning as never, + ]), + }, }); - await expect( - fetchWithX402(X402_URL, {}, { wallet, store }), - ).rejects.toThrow("x402: payment requirements missing scheme or network"); + await expect(fetchWithX402(X402_URL, {}, { wallet })).rejects.toThrow( + "x402: unsupported x402 network, only lightning networks are supported", + ); }); - test("throws when network is not a lightning network", async () => { + test("throws when requirements missing invoice", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); - const bad = { - scheme: "exact", - network: "bitcoin:mainnet", - extra: { invoice: INVOICE }, - }; + const bad = { scheme: "exact", network: "lightning:mainnet", extra: {} }; fetchMock.mockResponseOnce("Payment Required", { status: 402, headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader(bad as never) }, }); - await expect( - fetchWithX402(X402_URL, {}, { wallet, store }), - ).rejects.toThrow('x402: unsupported network "bitcoin:mainnet"'); + await expect(fetchWithX402(X402_URL, {}, { wallet })).rejects.toThrow( + "x402: payment requirements missing lightning invoice", + ); }); test("accepts lightning:testnet network", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); const testnet = { scheme: "exact", network: "lightning:testnet", @@ -365,75 +326,64 @@ describe("fetchWithX402", () => { fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); await expect( - fetchWithX402(X402_URL, {}, { wallet, store }), + fetchWithX402(X402_URL, {}, { wallet }), ).resolves.toBeDefined(); expect(wallet.payInvoice).toHaveBeenCalledTimes(1); }); - test("throws when wallet returns no preimage", async () => { - const wallet = { payInvoice: jest.fn().mockResolvedValue({}) }; - const store = new MemoryStorage(); + test("picks first lightning entry when accepts contains mixed networks", async () => { + const wallet = makeWallet(); + const nonLightning = { + scheme: "exact", + network: "bitcoin:mainnet", + extra: { invoice: "other" }, + }; fetchMock.mockResponseOnce("Payment Required", { status: 402, - headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, + headers: { + "PAYMENT-REQUIRED": makePaymentRequiredHeader(REQUIREMENTS, [ + nonLightning as never, + REQUIREMENTS, + ]), + }, }); + fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); - await expect( - fetchWithX402(X402_URL, {}, { wallet, store }), - ).rejects.toThrow("x402: wallet did not return a preimage"); + await fetchWithX402(X402_URL, {}, { wallet }); + + expect(wallet.payInvoice).toHaveBeenCalledWith({ invoice: INVOICE }); }); test("propagates wallet payment errors", async () => { const wallet = { payInvoice: jest.fn().mockRejectedValue(new Error("payment failed")), }; - const store = new MemoryStorage(); - - fetchMock.mockResponseOnce("Payment Required", { - status: 402, - headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, - }); - - await expect( - fetchWithX402(X402_URL, {}, { wallet, store }), - ).rejects.toThrow("payment failed"); - }); - - test("works with sendPayment wallet method", async () => { - const wallet = { - sendPayment: jest.fn().mockResolvedValue({ preimage: PREIMAGE }), - }; - const store = new MemoryStorage(); fetchMock.mockResponseOnce("Payment Required", { status: 402, headers: { "PAYMENT-REQUIRED": makePaymentRequiredHeader() }, }); - fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); - - await fetchWithX402(X402_URL, {}, { wallet, store }); - expect(wallet.sendPayment).toHaveBeenCalledTimes(1); - expect(wallet.sendPayment).toHaveBeenCalledWith(INVOICE); + await expect(fetchWithX402(X402_URL, {}, { wallet })).rejects.toThrow( + "payment failed", + ); }); test("sets cache to no-store and mode to cors", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); - await fetchWithX402(X402_URL, {}, { wallet, store }); + await fetchWithX402(X402_URL, {}, { wallet }); const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; expect(fetchInit.cache).toBe("no-store"); expect(fetchInit.mode).toBe("cors"); }); - test("passes custom fetchArgs through to fetch calls", async () => { + test("passes custom fetchArgs through to all fetch calls", async () => { const wallet = makeWallet(); - const store = new MemoryStorage(); fetchMock.mockResponseOnce("Payment Required", { status: 402, @@ -444,7 +394,7 @@ describe("fetchWithX402", () => { await fetchWithX402( X402_URL, { method: "POST", headers: { "X-Custom": "value" } }, - { wallet, store }, + { wallet }, ); for (const call of fetchMock.mock.calls) { From ee426e674e337b0434eefd108d8e968be22e5f0a Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 14 Mar 2026 11:26:08 +0100 Subject: [PATCH 08/24] docs: update readme --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 87859fc..39b5bde 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ This library includes a `fetchWithL402` function to consume L402 protected resou - url: the L402 protected URL - fetchArgs: arguments are passed to the underlying `fetch()` function used to do the HTTP request - options: - - wallet: any object that implements `sendPayment(paymentRequest)` and returns `{ preimage }`. Used to pay the L402 invoice. + - wallet: any object (e.g. a NWC client) that implements `payInvoice({ invoice })` and returns `{ preimage }`. Used to pay the L402 invoice. - store: a key/value store object to persiste the l402 for each URL. The store must implement a `getItem()`/`setItem()` function as the browser's localStorage. By default a memory storage is used. ##### Examples @@ -232,13 +232,13 @@ This library includes a `fetchWithX402` function to consume X402-protected resou - url: the X402 protected URL - fetchArgs: arguments are passed to the underlying `fetch()` function used to do the HTTP request - options: - - wallet: any object that implements `payInvoice(paymentRequest)` or `sendPayment(paymentRequest)` and returns `{ preimage }`. Used to pay the X402 invoice. + - wallet: any object (e.g. a NWC client) that implements `payInvoice({ invoice })` and returns `{ preimage }`. Used to pay the X402 invoice. - store: a key/value store object to persist the payment proof for each URL. The store must implement a `getItem()`/`setItem()` function as the browser's localStorage. By default a memory storage is used. ##### Examples ```js -import { fetchWithX402 } from "@getalby/lightning-tools/x402"; +import { fetchWithX402 } from "@getalby/lightning-tools/l402"; // pass a wallet that implements payInvoice() // the payment proof will be stored in memory and reused for subsequent requests @@ -279,7 +279,7 @@ await fetchWithX402( - url: the protected URL - fetchArgs: arguments are passed to the underlying `fetch()` function used to do the HTTP request - options: - - wallet: any object that implements `sendPayment(paymentRequest)` or `payInvoice({ invoice })` and returns `{ preimage }`. Used to pay L402 and X402 invoices. + - wallet: any object that implements `payInvoice({ invoice })` and returns `{ preimage }`. Used to pay L402 and X402 invoices. - store: a key/value store object to persist the payment proof for each URL. The store must implement a `getItem()`/`setItem()` function as the browser's localStorage. By default no storage is used - pass `window.localStorage` or a similar store to enable caching. ##### Examples From 401d27f392ae1a0ecf60785118b364b6be95867b Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 14 Mar 2026 13:37:55 +0100 Subject: [PATCH 09/24] docs: readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 39b5bde..57ed346 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ This library includes a `fetchWithX402` function to consume X402-protected resou import { fetchWithX402 } from "@getalby/lightning-tools/l402"; // pass a wallet that implements payInvoice() -// the payment proof will be stored in memory and reused for subsequent requests +// the payment proof will not be stored by default. to reuse the proofs for subsequent requests provide a storage await fetchWithX402( "https://x402.albylabs.com/demo/quote", {}, From fe45a8779928afc152dfd4847bb4acedd568d2d6 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 14 Mar 2026 13:38:36 +0100 Subject: [PATCH 10/24] feat: use proper Header object to better handle http headers --- src/l402/fetch402.ts | 42 ++++++++++++++++++++++++------------------ src/l402/l402.test.ts | 20 ++++++++++++-------- src/l402/l402.ts | 14 ++++++-------- src/l402/x402.test.ts | 12 ++++++------ src/l402/x402.ts | 31 ++++++++++++++++++------------- 5 files changed, 66 insertions(+), 53 deletions(-) diff --git a/src/l402/fetch402.ts b/src/l402/fetch402.ts index c8898e6..d29103f 100644 --- a/src/l402/fetch402.ts +++ b/src/l402/fetch402.ts @@ -26,9 +26,8 @@ export const fetch402 = async ( } fetchArgs.cache = "no-store"; fetchArgs.mode = "cors"; - if (!fetchArgs.headers) { - fetchArgs.headers = {}; - } + const headers = new Headers(fetchArgs.headers ?? undefined); + fetchArgs.headers = headers; // Check cache — detect protocol from stored data structure const cachedRaw = store.getItem(url); @@ -36,8 +35,10 @@ export const fetch402 = async ( const cached = JSON.parse(cachedRaw); if (cached?.token && cached?.preimage) { // L402 cached - fetchArgs.headers["Authorization"] = - `${HEADER_KEY} ${cached.token}:${cached.preimage}`; + headers.set( + "Authorization", + `${HEADER_KEY} ${cached.token}:${cached.preimage}`, + ); return await fetch(url, fetchArgs); } if ( @@ -47,18 +48,21 @@ export const fetch402 = async ( cached?.requirements ) { // X402 cached - fetchArgs.headers["payment-signature"] = buildX402PaymentSignature( - cached.scheme, - cached.network, - cached.preimage, - cached.requirements, + headers.set( + "payment-signature", + buildX402PaymentSignature( + cached.scheme, + cached.network, + cached.preimage, + cached.requirements, + ), ); return await fetch(url, fetchArgs); } } // Initial request — advertise L402 support - fetchArgs.headers["Accept-Authenticate"] = HEADER_KEY; + headers.set("Accept-Authenticate", HEADER_KEY); const initResp = await fetch(url, fetchArgs); const l402Header = initResp.headers.get("www-authenticate"); @@ -71,8 +75,7 @@ export const fetch402 = async ( store.setItem(url, JSON.stringify({ token, preimage: invResp.preimage })); - fetchArgs.headers["Authorization"] = - `${HEADER_KEY} ${token}:${invResp.preimage}`; + headers.set("Authorization", `${HEADER_KEY} ${token}:${invResp.preimage}`); return await fetch(url, fetchArgs); } @@ -119,11 +122,14 @@ export const fetch402 = async ( }), ); - fetchArgs.headers["payment-signature"] = buildX402PaymentSignature( - requirements.scheme, - requirements.network, - invResp.preimage, - requirements, + headers.set( + "payment-signature", + buildX402PaymentSignature( + requirements.scheme, + requirements.network, + invResp.preimage, + requirements, + ), ); return await fetch(url, fetchArgs); } diff --git a/src/l402/l402.test.ts b/src/l402/l402.test.ts index 791399b..ce6ef9a 100644 --- a/src/l402/l402.test.ts +++ b/src/l402/l402.test.ts @@ -109,8 +109,10 @@ describe("fetchWithL402", () => { // Verify the second request includes the Authorization header const secondCallInit = fetchMock.mock.calls[1][1] as RequestInit; - const secondHeaders = secondCallInit.headers as Record; - expect(secondHeaders["Authorization"]).toBe(`L402 ${MACAROON}:${PREIMAGE}`); + const secondHeaders = secondCallInit.headers as Headers; + expect(secondHeaders.get("Authorization")).toBe( + `L402 ${MACAROON}:${PREIMAGE}`, + ); expect(response.status).toBe(200); expect(await response.json()).toEqual({ data: "paid content" }); @@ -160,8 +162,8 @@ describe("fetchWithL402", () => { // Verify the Authorization header was set from the cache const callInit = fetchMock.mock.calls[0][1] as RequestInit; - const headers = callInit.headers as Record; - expect(headers["Authorization"]).toBe(`L402 ${MACAROON}:${PREIMAGE}`); + const headers = callInit.headers as Headers; + expect(headers.get("Authorization")).toBe(`L402 ${MACAROON}:${PREIMAGE}`); expect(await response.json()).toEqual({ data: "cached access" }); }); @@ -253,9 +255,9 @@ describe("fetchWithL402", () => { // Both fetch calls should have the custom header and method for (const call of fetchMock.mock.calls) { const fetchInit = call[1] as RequestInit; - const headers = fetchInit.headers as Record; + const headers = fetchInit.headers as Headers; expect(fetchInit.method).toBe("POST"); - expect(headers["X-Custom"]).toBe("value"); + expect(headers.get("X-Custom")).toBe("value"); } }); @@ -304,7 +306,9 @@ describe("fetchWithL402", () => { expect(await response.json()).toEqual({ second: true }); const lastCallInit = fetchMock.mock.calls[2][1] as RequestInit; - const lastHeaders = lastCallInit.headers as Record; - expect(lastHeaders["Authorization"]).toBe(`L402 ${MACAROON}:${PREIMAGE}`); + const lastHeaders = lastCallInit.headers as Headers; + expect(lastHeaders.get("Authorization")).toBe( + `L402 ${MACAROON}:${PREIMAGE}`, + ); }); }); diff --git a/src/l402/l402.ts b/src/l402/l402.ts index 58a5912..654a906 100644 --- a/src/l402/l402.ts +++ b/src/l402/l402.ts @@ -24,18 +24,17 @@ export const fetchWithL402 = async ( } fetchArgs.cache = "no-store"; fetchArgs.mode = "cors"; - if (!fetchArgs.headers) { - fetchArgs.headers = {}; - } + const headers = new Headers(fetchArgs.headers ?? undefined); + fetchArgs.headers = headers; + const cachedL402Data = store.getItem(url); if (cachedL402Data) { const data = JSON.parse(cachedL402Data); - fetchArgs.headers["Authorization"] = - `${headerKey} ${data.token}:${data.preimage}`; + headers.set("Authorization", `${headerKey} ${data.token}:${data.preimage}`); return await fetch(url, fetchArgs); } - fetchArgs.headers["Accept-Authenticate"] = headerKey; + headers.set("Accept-Authenticate", headerKey); const initResp = await fetch(url, fetchArgs); const header = initResp.headers.get("www-authenticate"); if (!header) { @@ -56,7 +55,6 @@ export const fetchWithL402 = async ( }), ); - fetchArgs.headers["Authorization"] = - `${headerKey} ${token}:${invResp.preimage}`; + headers.set("Authorization", `${headerKey} ${token}:${invResp.preimage}`); return await fetch(url, fetchArgs); }; diff --git a/src/l402/x402.test.ts b/src/l402/x402.test.ts index d02e7da..4e009cc 100644 --- a/src/l402/x402.test.ts +++ b/src/l402/x402.test.ts @@ -87,8 +87,8 @@ describe("fetchWithX402", () => { await fetchWithX402(X402_URL, {}, { wallet }); const secondCallInit = fetchMock.mock.calls[1][1] as RequestInit; - const headers = secondCallInit.headers as Record; - const sig = parsePaymentSignature(headers["payment-signature"]); + const headers = secondCallInit.headers as Headers; + const sig = parsePaymentSignature(headers.get("payment-signature")!); expect(sig).toMatchObject({ x402Version: 2, @@ -145,8 +145,8 @@ describe("fetchWithX402", () => { expect(await response.json()).toEqual({ data: "cached access" }); const callInit = fetchMock.mock.calls[0][1] as RequestInit; - const headers = callInit.headers as Record; - const sig = parsePaymentSignature(headers["payment-signature"]); + const headers = callInit.headers as Headers; + const sig = parsePaymentSignature(headers.get("payment-signature")!); expect(sig).toMatchObject({ x402Version: 2, scheme: REQUIREMENTS.scheme, @@ -399,9 +399,9 @@ describe("fetchWithX402", () => { for (const call of fetchMock.mock.calls) { const fetchInit = call[1] as RequestInit; - const headers = fetchInit.headers as Record; + const headers = fetchInit.headers as Headers; expect(fetchInit.method).toBe("POST"); - expect(headers["X-Custom"]).toBe("value"); + expect(headers.get("X-Custom")).toBe("value"); } }); }); diff --git a/src/l402/x402.ts b/src/l402/x402.ts index 8fdc56a..10db7d5 100644 --- a/src/l402/x402.ts +++ b/src/l402/x402.ts @@ -23,9 +23,8 @@ export const fetchWithX402 = async ( } fetchArgs.cache = "no-store"; fetchArgs.mode = "cors"; - if (!fetchArgs.headers) { - fetchArgs.headers = {}; - } + const headers = new Headers(fetchArgs.headers ?? undefined); + fetchArgs.headers = headers; const cachedRaw = store.getItem(url); if (cachedRaw) { @@ -42,11 +41,14 @@ export const fetchWithX402 = async ( cached?.preimage && cached?.requirements ) { - fetchArgs.headers["payment-signature"] = buildX402PaymentSignature( - cached.scheme, - cached.network, - cached.preimage, - cached.requirements, + headers.set( + "payment-signature", + buildX402PaymentSignature( + cached.scheme, + cached.network, + cached.preimage, + cached.requirements, + ), ); return await fetch(url, fetchArgs); } @@ -98,11 +100,14 @@ export const fetchWithX402 = async ( }), ); - fetchArgs.headers["payment-signature"] = buildX402PaymentSignature( - requirements.scheme, - requirements.network, - invResp.preimage, - requirements, + headers.set( + "payment-signature", + buildX402PaymentSignature( + requirements.scheme, + requirements.network, + invResp.preimage, + requirements, + ), ); return await fetch(url, fetchArgs); }; From bf1446422f9485a5659781b827839fdd809064fd Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Tue, 17 Mar 2026 12:08:41 +0100 Subject: [PATCH 11/24] fix: ai fixing the tests on CI --- jest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.ts b/jest.config.ts index db54545..bd3876a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,4 +1,4 @@ -import type { Config } from "ts-jest"; +import type { Config } from "jest"; const config: Config = { preset: "ts-jest", From e31ce7f0f2a58b84241e6d9d277fa2bd0e2f6f0f Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 20 Mar 2026 12:19:32 +0100 Subject: [PATCH 12/24] feat: expose millisats amount in Invoice some protocols (e.g. X402) work with millisats. To make it easier to for example validate the amount this directly exposes the millisats property. --- src/bolt11/Invoice.test.ts | 2 ++ src/bolt11/Invoice.ts | 4 ++++ src/bolt11/utils.ts | 8 ++++++++ 3 files changed, 14 insertions(+) diff --git a/src/bolt11/Invoice.test.ts b/src/bolt11/Invoice.test.ts index 897ad74..99afe83 100644 --- a/src/bolt11/Invoice.test.ts +++ b/src/bolt11/Invoice.test.ts @@ -23,6 +23,8 @@ describe("Invoice", () => { "9c0e57f7f1f4823ce6751fc3fd260e55fe12ccb7dbd70ab58e660c03f569ab34", ); expect(decodedInvoice.satoshi).toBe(1); + expect(decodedInvoice.millisatoshi).toBe(1000); + expect(decodedInvoice.amountRaw).toBe("1000"); expect(decodedInvoice.expiry).toBe(86400); expect(decodedInvoice.timestamp).toBe(1699966882); expect(decodedInvoice.createdDate.toISOString()).toBe( diff --git a/src/bolt11/Invoice.ts b/src/bolt11/Invoice.ts index f476cac..ba43593 100644 --- a/src/bolt11/Invoice.ts +++ b/src/bolt11/Invoice.ts @@ -9,6 +9,8 @@ export class Invoice { preimage: string | null; verify: string | null; satoshi: number; + millisatoshi: number; + amountRaw: string; expiry: number | undefined; // expiry in seconds (not a timestamp) timestamp: number; // created date in seconds createdDate: Date; @@ -27,6 +29,8 @@ export class Invoice { } this.paymentHash = decodedInvoice.paymentHash; this.satoshi = decodedInvoice.satoshi; + this.millisatoshi = decodedInvoice.millisatoshi; + this.amountRaw = decodedInvoice.amountRaw; this.timestamp = decodedInvoice.timestamp; this.expiry = decodedInvoice.expiry; this.createdDate = new Date(this.timestamp * 1000); diff --git a/src/bolt11/utils.ts b/src/bolt11/utils.ts index b7a5f60..b54fa54 100644 --- a/src/bolt11/utils.ts +++ b/src/bolt11/utils.ts @@ -9,6 +9,8 @@ export const fromHexString = (hexString: string) => type DecodedInvoice = { paymentHash: string; satoshi: number; + millisatoshi: number; + amountRaw: string; timestamp: number; expiry: number | undefined; description: string | undefined; @@ -32,10 +34,14 @@ export const decodeInvoice = ( const paymentHash = hashTag.value; let satoshi = 0; + let millisatoshi = 0; + let amountRaw = "0"; const amountTag = decoded.sections.find((value) => value.name === "amount"); if (amountTag?.name === "amount" && amountTag.value) { + amountRaw = amountTag.value; + millisatoshi = parseInt(amountTag.value); satoshi = parseInt(amountTag.value) / 1000; // millisats } @@ -66,6 +72,8 @@ export const decodeInvoice = ( return { paymentHash, satoshi, + millisatoshi, + amountRaw, timestamp, expiry, description, From 1dd777ec4820cdd60d1c7ec60ce6274cd2c80693 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 20 Mar 2026 12:25:54 +0100 Subject: [PATCH 13/24] feat: new X402 lightning spec This also adds amount validation to check the invoice amount is of the advertised amount. --- src/l402/utils.ts | 1 + src/l402/x402.ts | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/l402/utils.ts b/src/l402/utils.ts index 9d41a4c..1111252 100644 --- a/src/l402/utils.ts +++ b/src/l402/utils.ts @@ -69,6 +69,7 @@ export interface X402Requirements { network: string; extra: { invoice: string; + paymentMethod?: string; [key: string]: unknown; }; [key: string]: unknown; diff --git a/src/l402/x402.ts b/src/l402/x402.ts index 10db7d5..1128458 100644 --- a/src/l402/x402.ts +++ b/src/l402/x402.ts @@ -5,6 +5,7 @@ import { Wallet, X402Requirements, } from "./utils"; +import { Invoice } from "../bolt11"; const noStorage = new NoStorage(); @@ -76,7 +77,7 @@ export const fetchWithX402 = async ( } const requirements = (parsed.accepts as X402Requirements[]).find((e) => { - return e.network.startsWith("lightning"); + return e.network.startsWith("btc") && e.extra.paymentMethod === "lightning"; }); if (!requirements) { throw new Error( @@ -87,8 +88,14 @@ export const fetchWithX402 = async ( throw new Error("x402: payment requirements missing lightning invoice"); } - const invoice = requirements.extra.invoice; - const invResp = await wallet.payInvoice!({ invoice }); + const invoice = new Invoice({ pr: requirements.extra.invoice }); + if (invoice.amountRaw != requirements.amount) { + throw new Error( + `Invalid invoice amount: ${invoice.amountRaw}. expected ${requirements.amount}`, + ); + } + + const invResp = await wallet.payInvoice!({ invoice: invoice.paymentRequest }); store.setItem( url, From 35e9c992dfca1bc4f6681c296e55f9de853fe7da Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 20 Mar 2026 13:36:59 +0100 Subject: [PATCH 14/24] test: adjust tests --- src/l402/x402.test.ts | 56 +++++++++++-------------------------------- src/l402/x402.ts | 4 ++-- 2 files changed, 16 insertions(+), 44 deletions(-) diff --git a/src/l402/x402.test.ts b/src/l402/x402.test.ts index 4e009cc..5450f62 100644 --- a/src/l402/x402.test.ts +++ b/src/l402/x402.test.ts @@ -3,16 +3,18 @@ import { fetchWithX402 } from "./x402"; import { MemoryStorage, NoStorage } from "./utils"; const INVOICE = - "lnbc100n1pjkse4mpp5q22x8xdwrmpw0t6cww6sey7fn6klnnr5303vj7h44tr3dm2c9y9qdq8f4f5z4qcqzzsxqyz5vqsp5mmhp6cx4xxysc8xvxaj984eue9pm83lxgezmk3umx6wxr9rrq2ns9qyyssqmmrrwthves6z3d85nafj2ds4z20qju2vpaatep8uwrvxz0xs4kznm99m7f6pmkzax09k2k9saldy34z0p0l8gm0zm5xsmg2g667pnlqp7a0qdz"; + "lnbc4020n1p5m6028dq80q6rqvsnp4qt5w34u6kntf5lc50jj27rvs89sgrpcpj7s6vfts042gkhxx2j6swpp5g6tquvmswkv5xf0ru7ju2qvdrf83l2ewha3qzzt0a7vurs5q30rssp54kt5hfzjngjersx8fgt60feuu8e7vnat67f3ksr98twdj7z0m0ls9qyysgqcqzp2xqyz5vqrzjqdc22wfv6lyplagj37n9dmndkrzdz8rh3lxkewvvk6arkjpefats2rf47yqqwysqqcqqqqlgqqqqqqgqfqrzjq26922n6s5n5undqrf78rjjhgpcczafws45tx8237y7pzx3fg8ww8apyqqqqqqqqjyqqqqlgqqqqr4gq2q3z5pu33awfm98ac3ysdhy046xmen4zqval67tccu35x9mxgvl6w3wmq6y03ae7pme6qr20mp5gvuqntnu8yy7nlf6gyt9zshanj2zhgqe4xde3"; const PREIMAGE = - "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + "8196e90022ce688d911554d02af67d3d6a72143961c1e1aa12c4720538ea0549"; const X402_URL = "https://example.com/protected"; const REQUIREMENTS = { scheme: "exact", - network: "lightning:mainnet", - extra: { invoice: INVOICE }, + network: "bip122:000000000019d6689c085ae165831e93", + amount: "402000", + asset: "btc", + extra: { invoice: INVOICE, paymentMethod: "lightning" }, }; function makePaymentRequiredHeader( @@ -90,13 +92,9 @@ describe("fetchWithX402", () => { const headers = secondCallInit.headers as Headers; const sig = parsePaymentSignature(headers.get("payment-signature")!); - expect(sig).toMatchObject({ - x402Version: 2, - scheme: REQUIREMENTS.scheme, - network: REQUIREMENTS.network, - payload: { preimage: PREIMAGE }, - accepted: REQUIREMENTS, - }); + const payload = sig.payload as { preimage: string }; + expect(payload.preimage).toEqual(PREIMAGE); + expect(sig.accepted).toEqual(REQUIREMENTS); }); test("stores payment data after successful payment", async () => { @@ -147,12 +145,8 @@ describe("fetchWithX402", () => { const callInit = fetchMock.mock.calls[0][1] as RequestInit; const headers = callInit.headers as Headers; const sig = parsePaymentSignature(headers.get("payment-signature")!); - expect(sig).toMatchObject({ - x402Version: 2, - scheme: REQUIREMENTS.scheme, - network: REQUIREMENTS.network, - payload: { preimage: PREIMAGE }, - }); + const payload = sig.payload as { preimage: string }; + expect(payload.preimage).toEqual(PREIMAGE); }); test("second request reuses cached data without re-paying", async () => { @@ -277,7 +271,7 @@ describe("fetchWithX402", () => { const wallet = makeWallet(); const nonLightning = { scheme: "exact", - network: "bitcoin:mainnet", + network: "bip122:something", extra: { invoice: INVOICE }, }; @@ -291,7 +285,7 @@ describe("fetchWithX402", () => { }); await expect(fetchWithX402(X402_URL, {}, { wallet })).rejects.toThrow( - "x402: unsupported x402 network, only lightning networks are supported", + "x402: unsupported x402 network, only Bitcoin lightning network is supported.", ); }); @@ -305,32 +299,10 @@ describe("fetchWithX402", () => { }); await expect(fetchWithX402(X402_URL, {}, { wallet })).rejects.toThrow( - "x402: payment requirements missing lightning invoice", + "x402: unsupported x402 network, only Bitcoin lightning network is supported.", ); }); - test("accepts lightning:testnet network", async () => { - const wallet = makeWallet(); - const testnet = { - scheme: "exact", - network: "lightning:testnet", - extra: { invoice: INVOICE }, - }; - - fetchMock.mockResponseOnce("Payment Required", { - status: 402, - headers: { - "PAYMENT-REQUIRED": makePaymentRequiredHeader(testnet as never), - }, - }); - fetchMock.mockResponseOnce(JSON.stringify({ ok: true }), { status: 200 }); - - await expect( - fetchWithX402(X402_URL, {}, { wallet }), - ).resolves.toBeDefined(); - expect(wallet.payInvoice).toHaveBeenCalledTimes(1); - }); - test("picks first lightning entry when accepts contains mixed networks", async () => { const wallet = makeWallet(); const nonLightning = { diff --git a/src/l402/x402.ts b/src/l402/x402.ts index 1128458..0076403 100644 --- a/src/l402/x402.ts +++ b/src/l402/x402.ts @@ -77,11 +77,11 @@ export const fetchWithX402 = async ( } const requirements = (parsed.accepts as X402Requirements[]).find((e) => { - return e.network.startsWith("btc") && e.extra.paymentMethod === "lightning"; + return e.extra.paymentMethod === "lightning"; }); if (!requirements) { throw new Error( - "x402: unsupported x402 network, only lightning networks are supported", + "x402: unsupported x402 network, only Bitcoin lightning network is supported.", ); } if (!requirements.extra?.invoice) { From ff358fa3180891baafa0befab45cad96ba7b3d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Mon, 23 Mar 2026 17:21:12 +0100 Subject: [PATCH 15/24] fix: updated paymentMethod, remove preimage --- src/l402/fetch402.ts | 12 ++++++------ src/l402/utils.ts | 4 ++-- src/l402/x402.test.ts | 20 ++++++++++---------- src/l402/x402.ts | 10 +++++----- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/l402/fetch402.ts b/src/l402/fetch402.ts index d29103f..c85caa7 100644 --- a/src/l402/fetch402.ts +++ b/src/l402/fetch402.ts @@ -44,7 +44,7 @@ export const fetch402 = async ( if ( cached?.scheme && cached?.network && - cached?.preimage && + cached?.invoice && cached?.requirements ) { // X402 cached @@ -53,7 +53,7 @@ export const fetch402 = async ( buildX402PaymentSignature( cached.scheme, cached.network, - cached.preimage, + cached.invoice, cached.requirements, ), ); @@ -98,7 +98,7 @@ export const fetch402 = async ( } const requirements = (parsed.accepts as X402Requirements[]).find((e) => { - return e.network.startsWith("lightning"); + return e.extra?.paymentMethod === "lightning"; }); if (!requirements) { throw new Error( @@ -110,14 +110,14 @@ export const fetch402 = async ( } const invoice = requirements.extra.invoice; - const invResp = await wallet.payInvoice!({ invoice }); + await wallet.payInvoice!({ invoice }); store.setItem( url, JSON.stringify({ scheme: requirements.scheme, network: requirements.network, - preimage: invResp.preimage, + invoice, requirements, }), ); @@ -127,7 +127,7 @@ export const fetch402 = async ( buildX402PaymentSignature( requirements.scheme, requirements.network, - invResp.preimage, + invoice, requirements, ), ); diff --git a/src/l402/utils.ts b/src/l402/utils.ts index 1111252..1731aba 100644 --- a/src/l402/utils.ts +++ b/src/l402/utils.ts @@ -78,14 +78,14 @@ export interface X402Requirements { export const buildX402PaymentSignature = ( scheme: string, network: string, - preimage: string, + invoice: string, requirements: X402Requirements, ): string => { const json = JSON.stringify({ x402Version: 2, scheme, network, - payload: { preimage }, + payload: { invoice }, accepted: requirements, }); // btoa only handles latin1; encode via UTF-8 to be safe diff --git a/src/l402/x402.test.ts b/src/l402/x402.test.ts index 5450f62..9b2c0d9 100644 --- a/src/l402/x402.test.ts +++ b/src/l402/x402.test.ts @@ -92,8 +92,8 @@ describe("fetchWithX402", () => { const headers = secondCallInit.headers as Headers; const sig = parsePaymentSignature(headers.get("payment-signature")!); - const payload = sig.payload as { preimage: string }; - expect(payload.preimage).toEqual(PREIMAGE); + const payload = sig.payload as { invoice: string }; + expect(payload.invoice).toEqual(INVOICE); expect(sig.accepted).toEqual(REQUIREMENTS); }); @@ -109,11 +109,11 @@ describe("fetchWithX402", () => { await fetchWithX402(X402_URL, {}, { wallet, store }); - const stored = JSON.parse(store.getItem(X402_URL)); + const stored = JSON.parse(store.getItem(X402_URL) as string); expect(stored).toMatchObject({ scheme: REQUIREMENTS.scheme, network: REQUIREMENTS.network, - preimage: PREIMAGE, + invoice: INVOICE, requirements: REQUIREMENTS, }); }); @@ -127,7 +127,7 @@ describe("fetchWithX402", () => { JSON.stringify({ scheme: REQUIREMENTS.scheme, network: REQUIREMENTS.network, - preimage: PREIMAGE, + invoice: INVOICE, requirements: REQUIREMENTS, }), ); @@ -145,8 +145,8 @@ describe("fetchWithX402", () => { const callInit = fetchMock.mock.calls[0][1] as RequestInit; const headers = callInit.headers as Headers; const sig = parsePaymentSignature(headers.get("payment-signature")!); - const payload = sig.payload as { preimage: string }; - expect(payload.preimage).toEqual(PREIMAGE); + const payload = sig.payload as { invoice: string }; + expect(payload.invoice).toEqual(INVOICE); }); test("second request reuses cached data without re-paying", async () => { @@ -202,7 +202,7 @@ describe("fetchWithX402", () => { expect(fetchMock).toHaveBeenCalledTimes(4); }); - test("falls through on incomplete cache entry (missing preimage)", async () => { + test("falls through on incomplete cache entry (missing invoice)", async () => { const wallet = makeWallet(); const store = new MemoryStorage(); @@ -210,8 +210,8 @@ describe("fetchWithX402", () => { X402_URL, JSON.stringify({ scheme: "exact", - network: "lightning:mainnet", - // no preimage, no requirements + network: "bip122:000000000019d6689c085ae165831e93", + // no invoice, no requirements }), ); diff --git a/src/l402/x402.ts b/src/l402/x402.ts index 0076403..2fabaa8 100644 --- a/src/l402/x402.ts +++ b/src/l402/x402.ts @@ -32,14 +32,14 @@ export const fetchWithX402 = async ( let cached: { scheme: string; network: string; - preimage: string; + invoice: string; requirements: X402Requirements; } | null = null; cached = JSON.parse(cachedRaw); if ( cached?.scheme && cached?.network && - cached?.preimage && + cached?.invoice && cached?.requirements ) { headers.set( @@ -47,7 +47,7 @@ export const fetchWithX402 = async ( buildX402PaymentSignature( cached.scheme, cached.network, - cached.preimage, + cached.invoice, cached.requirements, ), ); @@ -102,7 +102,7 @@ export const fetchWithX402 = async ( JSON.stringify({ scheme: requirements.scheme, network: requirements.network, - preimage: invResp.preimage, + invoice: invoice.paymentRequest, requirements, }), ); @@ -112,7 +112,7 @@ export const fetchWithX402 = async ( buildX402PaymentSignature( requirements.scheme, requirements.network, - invResp.preimage, + invoice.paymentRequest, requirements, ), ); From ca4bd17f99d42c2805b6c81338dce2d20bd5cbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Mon, 23 Mar 2026 21:32:30 +0100 Subject: [PATCH 16/24] fix: split x402 / l402 + shared utils --- package.json | 10 ++-- rollup.config.js | 5 +- src/{l402 => 402}/fetch402.ts | 14 ++--- src/{l402 => 402}/index.ts | 4 +- src/402/l402/index.ts | 2 + src/{ => 402}/l402/l402.test.ts | 8 +-- src/{ => 402}/l402/l402.ts | 5 +- src/402/l402/utils.ts | 30 ++++++++++ src/402/utils.ts | 34 +++++++++++ src/402/x402/index.ts | 2 + src/402/x402/utils.ts | 27 +++++++++ src/{l402 => 402/x402}/x402.test.ts | 2 +- src/{l402 => 402/x402}/x402.ts | 18 ++---- src/index.ts | 4 +- src/l402/utils.ts | 93 ----------------------------- src/x402/index.ts | 3 - 16 files changed, 122 insertions(+), 139 deletions(-) rename src/{l402 => 402}/fetch402.ts (93%) rename src/{l402 => 402}/index.ts (52%) create mode 100644 src/402/l402/index.ts rename src/{ => 402}/l402/l402.test.ts (98%) rename src/{ => 402}/l402/l402.ts (91%) create mode 100644 src/402/l402/utils.ts create mode 100644 src/402/utils.ts create mode 100644 src/402/x402/index.ts create mode 100644 src/402/x402/utils.ts rename src/{l402 => 402/x402}/x402.test.ts (99%) rename src/{l402 => 402/x402}/x402.ts (90%) delete mode 100644 src/l402/utils.ts delete mode 100644 src/x402/index.ts diff --git a/package.json b/package.json index d1add80..8a6c096 100644 --- a/package.json +++ b/package.json @@ -39,12 +39,12 @@ "require": "./dist/cjs/fiat.cjs", "types": "./dist/types/fiat.d.ts" }, - "./l402": { - "import": "./dist/esm/l402.js", - "require": "./dist/cjs/l402.cjs", - "types": "./dist/types/l402.d.ts" - }, "./402": { + "import": "./dist/esm/402.js", + "require": "./dist/cjs/402.cjs", + "types": "./dist/types/402.d.ts" + }, + "./l402": { "import": "./dist/esm/l402.js", "require": "./dist/cjs/l402.cjs", "types": "./dist/types/l402.d.ts" diff --git a/rollup.config.js b/rollup.config.js index e52b885..ddf108e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -19,8 +19,9 @@ const entries = [ { name: "index", input: "src/index.ts" }, { name: "bolt11", input: "src/bolt11/index.ts" }, { name: "fiat", input: "src/fiat/index.ts" }, - { name: "l402", input: "src/l402/index.ts" }, - { name: "x402", input: "src/x402/index.ts" }, + { name: "402", input: "src/402/index.ts" }, + { name: "l402", input: "src/402/l402/index.ts" }, + { name: "x402", input: "src/402/x402/index.ts" }, { name: "lnurl", input: "src/lnurl/index.ts" }, { name: "podcasting2", input: "src/podcasting2/index.ts" }, ]; diff --git a/src/l402/fetch402.ts b/src/402/fetch402.ts similarity index 93% rename from src/l402/fetch402.ts rename to src/402/fetch402.ts index c85caa7..e73b514 100644 --- a/src/l402/fetch402.ts +++ b/src/402/fetch402.ts @@ -1,16 +1,10 @@ -import { - KVStorage, - NoStorage, - parseL402, - buildX402PaymentSignature, - Wallet, - X402Requirements, -} from "./utils"; +import { KVStorage, NoStorage, Wallet } from "./utils"; +import { parseL402 } from "./l402/utils"; +import { buildX402PaymentSignature, X402Requirements } from "./x402/utils"; +import { HEADER_KEY } from "./l402/l402"; const noStorage = new NoStorage(); -const HEADER_KEY = "L402"; - export const fetch402 = async ( url: string, fetchArgs: RequestInit, diff --git a/src/l402/index.ts b/src/402/index.ts similarity index 52% rename from src/l402/index.ts rename to src/402/index.ts index efcfe5b..7892759 100644 --- a/src/l402/index.ts +++ b/src/402/index.ts @@ -1,4 +1,2 @@ -export * from "./l402"; -export * from "./x402"; -export * from "./utils"; export * from "./fetch402"; +export * from "./utils"; diff --git a/src/402/l402/index.ts b/src/402/l402/index.ts new file mode 100644 index 0000000..1c6515f --- /dev/null +++ b/src/402/l402/index.ts @@ -0,0 +1,2 @@ +export * from "./l402"; +export * from "./utils"; diff --git a/src/l402/l402.test.ts b/src/402/l402/l402.test.ts similarity index 98% rename from src/l402/l402.test.ts rename to src/402/l402/l402.test.ts index ce6ef9a..4faee35 100644 --- a/src/l402/l402.test.ts +++ b/src/402/l402/l402.test.ts @@ -1,11 +1,7 @@ import fetchMock from "jest-fetch-mock"; import { fetchWithL402 } from "./l402"; -import { - MemoryStorage, - NoStorage, - parseL402, - makeL402AuthenticateHeader, -} from "./utils"; +import { MemoryStorage, NoStorage } from "../utils"; +import { parseL402, makeL402AuthenticateHeader } from "./utils"; const MACAROON = "AgEEbHNhdAJCAAAClGOZrh7C569Yc7UMk8merfnMdIviyXr1qscW7VgpChNl21LkZ8Jex5QiPp+E1VaabeJDuWmlrh/j583axFpNAAIXc2VydmljZXM9cmFuZG9tbnVtYmVyOjAAAiZyYW5kb21udW1iZXJfY2FwYWJpbGl0aZVzPWFkZCxzdWJ0cmFjdAAABiAvFpzXGyc+8d/I9nMKKvAYP8w7kUlhuxS0eFN2sqmqHQ=="; diff --git a/src/l402/l402.ts b/src/402/l402/l402.ts similarity index 91% rename from src/l402/l402.ts rename to src/402/l402/l402.ts index 654a906..6cfae6f 100644 --- a/src/l402/l402.ts +++ b/src/402/l402/l402.ts @@ -1,8 +1,9 @@ -import { KVStorage, NoStorage, parseL402, Wallet } from "./utils"; +import { KVStorage, NoStorage, Wallet } from "../utils"; +import { parseL402 } from "./utils"; const noStorage = new NoStorage(); -const HEADER_KEY = "L402"; +export const HEADER_KEY = "L402"; export const fetchWithL402 = async ( url: string, diff --git a/src/402/l402/utils.ts b/src/402/l402/utils.ts new file mode 100644 index 0000000..1365fa8 --- /dev/null +++ b/src/402/l402/utils.ts @@ -0,0 +1,30 @@ +export const parseL402 = (input: string): Record => { + // Remove the L402 and LSAT identifiers + const string = input.replace("L402", "").replace("LSAT", "").trim(); + + // Initialize an object to store the key-value pairs + const keyValuePairs = {}; + + // Regular expression to match key and (quoted or unquoted) value + const regex = /(\w+)=("([^"]*)"|'([^']*)'|([^,]*))/g; + let match; + + // Use regex to find all key-value pairs + while ((match = regex.exec(string)) !== null) { + // Key is always match[1] + // Value is either match[3] (double-quoted), match[4] (single-quoted), or match[5] (unquoted) + keyValuePairs[match[1]] = match[3] || match[4] || match[5]; + } + + return keyValuePairs; +}; + +export const makeL402AuthenticateHeader = (args: { + macaroon: string; + invoice: string; + key?: string; +}) => { + const key = args.key || "L402"; + + return `${key} macaroon="${args.macaroon}", invoice="${args.invoice}"`; +}; diff --git a/src/402/utils.ts b/src/402/utils.ts new file mode 100644 index 0000000..5472c5f --- /dev/null +++ b/src/402/utils.ts @@ -0,0 +1,34 @@ +export interface KVStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export interface Wallet { + payInvoice(args: { invoice: string }): Promise<{ preimage: string }>; +} + +export class MemoryStorage implements KVStorage { + storage; + + constructor(initial?: Record) { + this.storage = initial || {}; + } + + getItem(key: string) { + return this.storage[key]; + } + + setItem(key: string, value: unknown) { + this.storage[key] = value; + } +} + +export class NoStorage implements KVStorage { + constructor(initial?: unknown) {} + + getItem(key: string) { + return null; + } + + setItem(key: string, value: unknown) {} +} diff --git a/src/402/x402/index.ts b/src/402/x402/index.ts new file mode 100644 index 0000000..bbf6158 --- /dev/null +++ b/src/402/x402/index.ts @@ -0,0 +1,2 @@ +export * from "./x402"; +export * from "./utils"; diff --git a/src/402/x402/utils.ts b/src/402/x402/utils.ts new file mode 100644 index 0000000..dbbd66b --- /dev/null +++ b/src/402/x402/utils.ts @@ -0,0 +1,27 @@ +export interface X402Requirements { + scheme: string; + network: string; + extra: { + invoice: string; + paymentMethod?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export const buildX402PaymentSignature = ( + scheme: string, + network: string, + invoice: string, + requirements: X402Requirements, +): string => { + const json = JSON.stringify({ + x402Version: 2, + scheme, + network, + payload: { invoice }, + accepted: requirements, + }); + // btoa only handles latin1; encode via UTF-8 to be safe + return btoa(unescape(encodeURIComponent(json))); +}; diff --git a/src/l402/x402.test.ts b/src/402/x402/x402.test.ts similarity index 99% rename from src/l402/x402.test.ts rename to src/402/x402/x402.test.ts index 9b2c0d9..17b810c 100644 --- a/src/l402/x402.test.ts +++ b/src/402/x402/x402.test.ts @@ -1,6 +1,6 @@ import fetchMock from "jest-fetch-mock"; import { fetchWithX402 } from "./x402"; -import { MemoryStorage, NoStorage } from "./utils"; +import { MemoryStorage, NoStorage } from "../utils"; const INVOICE = "lnbc4020n1p5m6028dq80q6rqvsnp4qt5w34u6kntf5lc50jj27rvs89sgrpcpj7s6vfts042gkhxx2j6swpp5g6tquvmswkv5xf0ru7ju2qvdrf83l2ewha3qzzt0a7vurs5q30rssp54kt5hfzjngjersx8fgt60feuu8e7vnat67f3ksr98twdj7z0m0ls9qyysgqcqzp2xqyz5vqrzjqdc22wfv6lyplagj37n9dmndkrzdz8rh3lxkewvvk6arkjpefats2rf47yqqwysqqcqqqqlgqqqqqqgqfqrzjq26922n6s5n5undqrf78rjjhgpcczafws45tx8237y7pzx3fg8ww8apyqqqqqqqqjyqqqqlgqqqqr4gq2q3z5pu33awfm98ac3ysdhy046xmen4zqval67tccu35x9mxgvl6w3wmq6y03ae7pme6qr20mp5gvuqntnu8yy7nlf6gyt9zshanj2zhgqe4xde3"; diff --git a/src/l402/x402.ts b/src/402/x402/x402.ts similarity index 90% rename from src/l402/x402.ts rename to src/402/x402/x402.ts index 2fabaa8..4b2afef 100644 --- a/src/l402/x402.ts +++ b/src/402/x402/x402.ts @@ -1,21 +1,13 @@ -import { - KVStorage, - NoStorage, - buildX402PaymentSignature, - Wallet, - X402Requirements, -} from "./utils"; -import { Invoice } from "../bolt11"; +import { KVStorage, NoStorage, Wallet } from "../utils"; +import { buildX402PaymentSignature, X402Requirements } from "./utils"; +import { Invoice } from "../../bolt11"; const noStorage = new NoStorage(); export const fetchWithX402 = async ( url: string, fetchArgs: RequestInit, - options: { - wallet: Wallet; - store?: KVStorage; - }, + options: { wallet: Wallet; store?: KVStorage }, ) => { const wallet = options.wallet; const store = options.store || noStorage; @@ -95,7 +87,7 @@ export const fetchWithX402 = async ( ); } - const invResp = await wallet.payInvoice!({ invoice: invoice.paymentRequest }); + await wallet.payInvoice!({ invoice: invoice.paymentRequest }); store.setItem( url, diff --git a/src/index.ts b/src/index.ts index 4a5c620..85d2297 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ export * from "./bolt11"; export * from "./lnurl"; export * from "./podcasting2"; -export * from "./l402"; +export * from "./402/l402"; +export * from "./402/x402"; +export * from "./402"; export * from "./fiat"; diff --git a/src/l402/utils.ts b/src/l402/utils.ts deleted file mode 100644 index 1731aba..0000000 --- a/src/l402/utils.ts +++ /dev/null @@ -1,93 +0,0 @@ -export interface KVStorage { - getItem(key: string): string | null; - setItem(key: string, value: string): void; -} - -export interface Wallet { - payInvoice(args: { invoice: string }): Promise<{ preimage: string }>; -} - -export class MemoryStorage implements KVStorage { - storage; - - constructor(initial?: Record) { - this.storage = initial || {}; - } - - getItem(key: string) { - return this.storage[key]; - } - - setItem(key: string, value: unknown) { - this.storage[key] = value; - } -} - -export class NoStorage implements KVStorage { - constructor(initial?: unknown) {} - - getItem(key: string) { - return null; - } - - setItem(key: string, value: unknown) {} -} - -export const parseL402 = (input: string): Record => { - // Remove the L402 and LSAT identifiers - const string = input.replace("L402", "").replace("LSAT", "").trim(); - - // Initialize an object to store the key-value pairs - const keyValuePairs = {}; - - // Regular expression to match key and (quoted or unquoted) value - const regex = /(\w+)=("([^"]*)"|'([^']*)'|([^,]*))/g; - let match; - - // Use regex to find all key-value pairs - while ((match = regex.exec(string)) !== null) { - // Key is always match[1] - // Value is either match[3] (double-quoted), match[4] (single-quoted), or match[5] (unquoted) - keyValuePairs[match[1]] = match[3] || match[4] || match[5]; - } - - return keyValuePairs; -}; - -export const makeL402AuthenticateHeader = (args: { - macaroon: string; - invoice: string; - key?: string; -}) => { - const key = args.key || "L402"; - - return `${key} macaroon="${args.macaroon}", invoice="${args.invoice}"`; -}; - -export interface X402Requirements { - scheme: string; - network: string; - extra: { - invoice: string; - paymentMethod?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -export const buildX402PaymentSignature = ( - scheme: string, - network: string, - invoice: string, - requirements: X402Requirements, -): string => { - const json = JSON.stringify({ - x402Version: 2, - scheme, - network, - payload: { invoice }, - accepted: requirements, - }); - // btoa only handles latin1; encode via UTF-8 to be safe - return btoa(unescape(encodeURIComponent(json))); -}; diff --git a/src/x402/index.ts b/src/x402/index.ts deleted file mode 100644 index 6463c7c..0000000 --- a/src/x402/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "../l402/x402"; -export * from "../l402/l402"; -export * from "../l402/utils"; From 27b1b5b0bcbf06f210ad541d3dd05b366c81149c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Tue, 24 Mar 2026 10:15:59 +0100 Subject: [PATCH 17/24] fix: unify handle 402 --- src/402/fetch402.ts | 69 ++------------------------- src/402/l402/l402.ts | 43 ++++++++++------- src/402/x402/x402.ts | 111 ++++++++++++++++++++++++------------------- 3 files changed, 93 insertions(+), 130 deletions(-) diff --git a/src/402/fetch402.ts b/src/402/fetch402.ts index e73b514..e983db8 100644 --- a/src/402/fetch402.ts +++ b/src/402/fetch402.ts @@ -1,7 +1,7 @@ import { KVStorage, NoStorage, Wallet } from "./utils"; -import { parseL402 } from "./l402/utils"; -import { buildX402PaymentSignature, X402Requirements } from "./x402/utils"; -import { HEADER_KEY } from "./l402/l402"; +import { buildX402PaymentSignature } from "./x402/utils"; +import { HEADER_KEY, handleL402Payment } from "./l402/l402"; +import { handleX402Payment } from "./x402/x402"; const noStorage = new NoStorage(); @@ -61,71 +61,12 @@ export const fetch402 = async ( const l402Header = initResp.headers.get("www-authenticate"); if (l402Header) { - const details = parseL402(l402Header); - const token = details.token || details.macaroon; - const invoice = details.invoice; - - const invResp = await wallet.payInvoice!({ invoice }); - - store.setItem(url, JSON.stringify({ token, preimage: invResp.preimage })); - - headers.set("Authorization", `${HEADER_KEY} ${token}:${invResp.preimage}`); - - return await fetch(url, fetchArgs); + return handleL402Payment(l402Header, url, fetchArgs, headers, wallet, store, HEADER_KEY); } const x402Header = initResp.headers.get("PAYMENT-REQUIRED"); if (x402Header) { - let parsed: { accepts?: unknown[] }; - try { - parsed = JSON.parse(decodeURIComponent(escape(atob(x402Header)))); - } catch (_) { - throw new Error( - "x402: invalid PAYMENT-REQUIRED header (not valid base64-encoded JSON)", - ); - } - - if (!Array.isArray(parsed.accepts) || parsed.accepts.length === 0) { - throw new Error( - "x402: PAYMENT-REQUIRED header contains no payment options", - ); - } - - const requirements = (parsed.accepts as X402Requirements[]).find((e) => { - return e.extra?.paymentMethod === "lightning"; - }); - if (!requirements) { - throw new Error( - "x402: unsupported x402 network, only lightning networks are supported", - ); - } - if (!requirements.extra?.invoice) { - throw new Error("x402: payment requirements missing lightning invoice"); - } - - const invoice = requirements.extra.invoice; - await wallet.payInvoice!({ invoice }); - - store.setItem( - url, - JSON.stringify({ - scheme: requirements.scheme, - network: requirements.network, - invoice, - requirements, - }), - ); - - headers.set( - "payment-signature", - buildX402PaymentSignature( - requirements.scheme, - requirements.network, - invoice, - requirements, - ), - ); - return await fetch(url, fetchArgs); + return handleX402Payment(x402Header, url, fetchArgs, headers, wallet, store); } return initResp; diff --git a/src/402/l402/l402.ts b/src/402/l402/l402.ts index 6cfae6f..ce3526b 100644 --- a/src/402/l402/l402.ts +++ b/src/402/l402/l402.ts @@ -5,6 +5,32 @@ const noStorage = new NoStorage(); export const HEADER_KEY = "L402"; +export const handleL402Payment = async ( + l402Header: string, + url: string, + fetchArgs: RequestInit, + headers: Headers, + wallet: Wallet, + store: KVStorage, + headerKey: string, +): Promise => { + const details = parseL402(l402Header); + const token = details.token || details.macaroon; + const invoice = details.invoice; + + if (!token) { + throw new Error("L402: missing token/macaroon in WWW-Authenticate header"); + } + if (!invoice) { + throw new Error("L402: missing invoice in WWW-Authenticate header"); + } + + const invResp = await wallet.payInvoice({ invoice }); + store.setItem(url, JSON.stringify({ token, preimage: invResp.preimage })); + headers.set("Authorization", `${headerKey} ${token}:${invResp.preimage}`); + return fetch(url, fetchArgs); +}; + export const fetchWithL402 = async ( url: string, fetchArgs: RequestInit, @@ -42,20 +68,5 @@ export const fetchWithL402 = async ( return initResp; } - const details = parseL402(header); - const token = details.token || details.macaroon; - const invoice = details.invoice; - - const invResp = await wallet.payInvoice({ invoice }); - - store.setItem( - url, - JSON.stringify({ - token: token, - preimage: invResp.preimage, - }), - ); - - headers.set("Authorization", `${headerKey} ${token}:${invResp.preimage}`); - return await fetch(url, fetchArgs); + return handleL402Payment(header, url, fetchArgs, headers, wallet, store, headerKey); }; diff --git a/src/402/x402/x402.ts b/src/402/x402/x402.ts index 4b2afef..5acc022 100644 --- a/src/402/x402/x402.ts +++ b/src/402/x402/x402.ts @@ -4,58 +4,17 @@ import { Invoice } from "../../bolt11"; const noStorage = new NoStorage(); -export const fetchWithX402 = async ( +export const handleX402Payment = async ( + x402Header: string, url: string, fetchArgs: RequestInit, - options: { wallet: Wallet; store?: KVStorage }, -) => { - const wallet = options.wallet; - const store = options.store || noStorage; - if (!fetchArgs) { - fetchArgs = {}; - } - fetchArgs.cache = "no-store"; - fetchArgs.mode = "cors"; - const headers = new Headers(fetchArgs.headers ?? undefined); - fetchArgs.headers = headers; - - const cachedRaw = store.getItem(url); - if (cachedRaw) { - let cached: { - scheme: string; - network: string; - invoice: string; - requirements: X402Requirements; - } | null = null; - cached = JSON.parse(cachedRaw); - if ( - cached?.scheme && - cached?.network && - cached?.invoice && - cached?.requirements - ) { - headers.set( - "payment-signature", - buildX402PaymentSignature( - cached.scheme, - cached.network, - cached.invoice, - cached.requirements, - ), - ); - return await fetch(url, fetchArgs); - } - } - - const initResp = await fetch(url, fetchArgs); - const header = initResp.headers.get("PAYMENT-REQUIRED"); - if (!header) { - return initResp; - } - + headers: Headers, + wallet: Wallet, + store: KVStorage, +): Promise => { let parsed: { accepts?: unknown[] }; try { - parsed = JSON.parse(decodeURIComponent(escape(atob(header)))); + parsed = JSON.parse(decodeURIComponent(escape(atob(x402Header)))); } catch (_) { throw new Error( "x402: invalid PAYMENT-REQUIRED header (not valid base64-encoded JSON)", @@ -69,7 +28,7 @@ export const fetchWithX402 = async ( } const requirements = (parsed.accepts as X402Requirements[]).find((e) => { - return e.extra.paymentMethod === "lightning"; + return e.extra?.paymentMethod === "lightning"; }); if (!requirements) { throw new Error( @@ -108,5 +67,57 @@ export const fetchWithX402 = async ( requirements, ), ); - return await fetch(url, fetchArgs); + return fetch(url, fetchArgs); +}; + +export const fetchWithX402 = async ( + url: string, + fetchArgs: RequestInit, + options: { wallet: Wallet; store?: KVStorage }, +) => { + const wallet = options.wallet; + const store = options.store || noStorage; + if (!fetchArgs) { + fetchArgs = {}; + } + fetchArgs.cache = "no-store"; + fetchArgs.mode = "cors"; + const headers = new Headers(fetchArgs.headers ?? undefined); + fetchArgs.headers = headers; + + const cachedRaw = store.getItem(url); + if (cachedRaw) { + let cached: { + scheme: string; + network: string; + invoice: string; + requirements: X402Requirements; + } | null = null; + cached = JSON.parse(cachedRaw); + if ( + cached?.scheme && + cached?.network && + cached?.invoice && + cached?.requirements + ) { + headers.set( + "payment-signature", + buildX402PaymentSignature( + cached.scheme, + cached.network, + cached.invoice, + cached.requirements, + ), + ); + return await fetch(url, fetchArgs); + } + } + + const initResp = await fetch(url, fetchArgs); + const header = initResp.headers.get("PAYMENT-REQUIRED"); + if (!header) { + return initResp; + } + + return handleX402Payment(header, url, fetchArgs, headers, wallet, store); }; From 17e56486062055cee68c792626056af9d8d7a084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Tue, 24 Mar 2026 10:16:17 +0100 Subject: [PATCH 18/24] fix: unify examples --- examples/402.js | 10 ++++------ examples/l402.js | 19 +++++++------------ examples/x402.js | 19 ++++++------------- 3 files changed, 17 insertions(+), 31 deletions(-) diff --git a/examples/402.js b/examples/402.js index cc8e0d3..16100a2 100644 --- a/examples/402.js +++ b/examples/402.js @@ -1,4 +1,4 @@ -import { fetch402 } from "@getalby/lightning-tools/402"; +import { fetch402 } from "../src/402/index.ts"; import { NWCClient } from "@getalby/sdk"; // fetch402 works with both L402 and X402 endpoints — @@ -11,13 +11,11 @@ if (!nostrWalletConnectUrl) { throw new Error("Please set a NWC_URL env variable"); } -const nwc = new NWCClient({ - nostrWalletConnectUrl, -}); +const nwc = new NWCClient({ nostrWalletConnectUrl }); fetch402(url, {}, { wallet: nwc }) .then((response) => response.json()) .then((data) => { console.info(data); - nwc.close(); - }); + }) + .finally(() => nwc.close()); diff --git a/examples/l402.js b/examples/l402.js index d1d1f0e..6811b08 100644 --- a/examples/l402.js +++ b/examples/l402.js @@ -1,8 +1,8 @@ -import { fetchWithL402 } from "@getalby/lightning-tools/l402"; -import { NostrWebLNProvider } from "@getalby/sdk"; -import "websocket-polyfill"; +import { fetchWithL402 } from "../src/402/l402/index.ts"; +import { NWCClient } from "@getalby/sdk"; -const url = "https://lsat-weather-api.getalby.repl.co/kigali"; +const url = + process.env.URL || "https://lsat-weather-api.getalby.repl.co/kigali"; const nostrWalletConnectUrl = process.env.NWC_URL; @@ -10,16 +10,11 @@ if (!nostrWalletConnectUrl) { throw new Error("Please set a NWC_URL env variable"); } -const nwc = new NostrWebLNProvider({ - nostrWalletConnectUrl, -}); -nwc.on("sendPayment", (response) => { - console.info(`payment response:`, response); -}); +const nwc = new NWCClient({ nostrWalletConnectUrl }); fetchWithL402(url, {}, { wallet: nwc }) .then((response) => response.json()) .then((data) => { console.info(data); - nwc.close(); - }); \ No newline at end of file + }) + .finally(() => nwc.close()); diff --git a/examples/x402.js b/examples/x402.js index 39ba890..74aa839 100644 --- a/examples/x402.js +++ b/examples/x402.js @@ -1,8 +1,7 @@ -import { fetchWithX402 } from "@getalby/lightning-tools/l402"; -import { NostrWebLNProvider } from "@getalby/sdk"; -import "websocket-polyfill"; +import { fetchWithX402 } from "../src/402/x402/index.ts"; +import { NWCClient } from "@getalby/sdk"; -const url = "https://x402.albylabs.com/demo/quote"; +const url = process.env.URL || "https://x402.albylabs.com/demo/quote"; const nostrWalletConnectUrl = process.env.NWC_URL; @@ -10,17 +9,11 @@ if (!nostrWalletConnectUrl) { throw new Error("Please set a NWC_URL env variable"); } -const nwc = new NostrWebLNProvider({ - nostrWalletConnectUrl, -}); -await nwc.enable(); -nwc.on("payInvoice", (response) => { - console.info(`payment response:`, response); -}); +const nwc = new NWCClient({ nostrWalletConnectUrl }); fetchWithX402(url, {}, { wallet: nwc }) .then((response) => response.json()) .then((data) => { console.info(data); - nwc.close(); - }); + }) + .finally(() => nwc.close()); From a26f3c035eadff42a7ebe494cd8e9b498c67f3a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Tue, 24 Mar 2026 10:17:18 +0100 Subject: [PATCH 19/24] fix: formatting --- src/402/fetch402.ts | 19 +++++++++++++++++-- src/402/l402/l402.ts | 10 +++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/402/fetch402.ts b/src/402/fetch402.ts index e983db8..e681818 100644 --- a/src/402/fetch402.ts +++ b/src/402/fetch402.ts @@ -61,12 +61,27 @@ export const fetch402 = async ( const l402Header = initResp.headers.get("www-authenticate"); if (l402Header) { - return handleL402Payment(l402Header, url, fetchArgs, headers, wallet, store, HEADER_KEY); + return handleL402Payment( + l402Header, + url, + fetchArgs, + headers, + wallet, + store, + HEADER_KEY, + ); } const x402Header = initResp.headers.get("PAYMENT-REQUIRED"); if (x402Header) { - return handleX402Payment(x402Header, url, fetchArgs, headers, wallet, store); + return handleX402Payment( + x402Header, + url, + fetchArgs, + headers, + wallet, + store, + ); } return initResp; diff --git a/src/402/l402/l402.ts b/src/402/l402/l402.ts index ce3526b..10896fd 100644 --- a/src/402/l402/l402.ts +++ b/src/402/l402/l402.ts @@ -68,5 +68,13 @@ export const fetchWithL402 = async ( return initResp; } - return handleL402Payment(header, url, fetchArgs, headers, wallet, store, headerKey); + return handleL402Payment( + header, + url, + fetchArgs, + headers, + wallet, + store, + headerKey, + ); }; From 9ec59a72e07eae132145d4291108337f5c88c752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Tue, 24 Mar 2026 11:09:11 +0100 Subject: [PATCH 20/24] fix: make examples runnable --- examples/402.js | 2 +- examples/README.md | 50 ++++++++++++++++++++++++++++ examples/l402.js | 2 +- examples/package.json | 18 ++++++++++ examples/x402.js | 2 +- examples/yarn.lock | 76 +++++++++++++++++++++++++++++++++++++++++++ examples/zaps-nwc.js | 1 - 7 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/package.json create mode 100644 examples/yarn.lock diff --git a/examples/402.js b/examples/402.js index 16100a2..9404918 100644 --- a/examples/402.js +++ b/examples/402.js @@ -1,4 +1,4 @@ -import { fetch402 } from "../src/402/index.ts"; +import { fetch402 } from "@getalby/lightning-tools/402"; import { NWCClient } from "@getalby/sdk"; // fetch402 works with both L402 and X402 endpoints — diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..4474f52 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,50 @@ +# Examples + +Runnable examples for `@getalby/lightning-tools`. + +## Setup + +```bash +cd examples +yarn +``` + +> `yarn` links the local package via `file:..` — if you change library source, rebuild first: +> ```bash +> cd .. && yarn build && cd examples +> ``` + +## Running examples + +### x402 / L402 — paid API fetch + +These examples fetch a URL protected by an HTTP 402 payment wall. You need an [NWC](https://nwc.getalby.com) connection string. + +```bash +NWC_URL="nostr+walletconnect://..." yarn 402 # auto-detects L402 or x402 +NWC_URL="nostr+walletconnect://..." yarn x402 # x402 only +NWC_URL="nostr+walletconnect://..." yarn l402 # L402 only +``` + +Override the default URL: +```bash +URL="https://your-402-endpoint.example.com" NWC_URL="nostr+walletconnect://..." yarn 402 +``` + +### Lightning Address — request invoice + +Fetches LNURL-pay data for a lightning address and requests an invoice. No credentials needed. + +```bash +yarn request-invoice +``` + +### Zaps via NWC + +Sends a zap to a lightning address using NWC for payment and a Nostr key for signing. + +```bash +NOSTR_PRIVATE_KEY="your-hex-private-key" NWC_URL="nostr+walletconnect://..." yarn zaps-nwc +``` + +> **`zaps.js`** is a browser-only example (uses `window.webln`) and is not runnable as a Node script. diff --git a/examples/l402.js b/examples/l402.js index 6811b08..bd90ba4 100644 --- a/examples/l402.js +++ b/examples/l402.js @@ -1,4 +1,4 @@ -import { fetchWithL402 } from "../src/402/l402/index.ts"; +import { fetchWithL402 } from "@getalby/lightning-tools/l402"; import { NWCClient } from "@getalby/sdk"; const url = diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000..fa3dd94 --- /dev/null +++ b/examples/package.json @@ -0,0 +1,18 @@ +{ + "name": "@getalby/lightning-tools-examples", + "private": true, + "type": "module", + "scripts": { + "402": "node 402.js", + "l402": "node l402.js", + "x402": "node x402.js", + "request-invoice": "node request-invoice.js", + "zaps-nwc": "node zaps-nwc.js" + }, + "dependencies": { + "@getalby/lightning-tools": "file:..", + "@getalby/sdk": "^7.0.0", + "@noble/hashes": "^2.0.1", + "nostr-tools": "^2.16.1" + } +} diff --git a/examples/x402.js b/examples/x402.js index 74aa839..c58ec11 100644 --- a/examples/x402.js +++ b/examples/x402.js @@ -1,4 +1,4 @@ -import { fetchWithX402 } from "../src/402/x402/index.ts"; +import { fetchWithX402 } from "@getalby/lightning-tools/x402"; import { NWCClient } from "@getalby/sdk"; const url = process.env.URL || "https://x402.albylabs.com/demo/quote"; diff --git a/examples/yarn.lock b/examples/yarn.lock new file mode 100644 index 0000000..6c52535 --- /dev/null +++ b/examples/yarn.lock @@ -0,0 +1,76 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@getalby/lightning-tools@^6.0.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-6.1.0.tgz#558b90a83b961cb6aa760e62de69f5b5ceeb2fe9" + integrity sha512-rGurar9X4Gm+9xwoNYS8s9YLK7ZYqvbqv4KbHLYV0LEeB0HxZHRgmxblGqg+fYfp6iiYHx+edIgUpt9rS3VwFw== + +"@getalby/lightning-tools@file:..": + version "7.0.2" + +"@getalby/sdk@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@getalby/sdk/-/sdk-7.0.0.tgz#6ab17f27bd9e762d383b70cfabd0cdeeded6bd53" + integrity sha512-0c8gyvFbRDHZIgHmOD/dfyPukxZLeidx/hx7SXlMIS/hsx4mXpKpo9Gx1zW90buElnd3k9TVB/S/bnFSEZPE7w== + dependencies: + "@getalby/lightning-tools" "^6.0.0" + nostr-tools "^2.17.0" + +"@noble/ciphers@2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-2.1.1.tgz#c8c74fcda8c3d1f88797d0ecda24f9fc8b92b052" + integrity sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw== + +"@noble/curves@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-2.0.1.tgz#64ba8bd5e8564a02942655602515646df1cdb3ad" + integrity sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw== + dependencies: + "@noble/hashes" "2.0.1" + +"@noble/hashes@2.0.1", "@noble/hashes@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + +"@scure/base@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" + integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== + +"@scure/bip32@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-2.0.1.tgz#4ceea207cee8626d3fe8f0b6ab68b6af8f81c482" + integrity sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA== + dependencies: + "@noble/curves" "2.0.1" + "@noble/hashes" "2.0.1" + "@scure/base" "2.0.0" + +"@scure/bip39@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.0.1.tgz#47a6dc15e04faf200041239d46ae3bb7c3c96add" + integrity sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg== + dependencies: + "@noble/hashes" "2.0.1" + "@scure/base" "2.0.0" + +nostr-tools@^2.16.1, nostr-tools@^2.17.0: + version "2.23.3" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.23.3.tgz#1a7501988b72499cf27c8f3951f00d11d9ac6025" + integrity sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA== + dependencies: + "@noble/ciphers" "2.1.1" + "@noble/curves" "2.0.1" + "@noble/hashes" "2.0.1" + "@scure/base" "2.0.0" + "@scure/bip32" "2.0.1" + "@scure/bip39" "2.0.1" + nostr-wasm "0.1.0" + +nostr-wasm@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/nostr-wasm/-/nostr-wasm-0.1.0.tgz#17af486745feb2b7dd29503fdd81613a24058d94" + integrity sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA== diff --git a/examples/zaps-nwc.js b/examples/zaps-nwc.js index 5163de8..d94cc8f 100644 --- a/examples/zaps-nwc.js +++ b/examples/zaps-nwc.js @@ -1,6 +1,5 @@ import { LightningAddress } from "@getalby/lightning-tools/lnurl"; import { NostrWebLNProvider } from "@getalby/sdk"; -import "websocket-polyfill"; import { finalizeEvent, getPublicKey } from "nostr-tools"; import { hexToBytes } from "@noble/hashes/utils"; From 92f1923668d4219b75fee3ad9cfebc8fe726c200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Tue, 24 Mar 2026 11:11:53 +0100 Subject: [PATCH 21/24] fix: links --- examples/README.md | 4 +++- examples/zaps-nwc.js | 7 ++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/examples/README.md b/examples/README.md index 4474f52..5dfd61e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,6 +10,7 @@ yarn ``` > `yarn` links the local package via `file:..` — if you change library source, rebuild first: +> > ```bash > cd .. && yarn build && cd examples > ``` @@ -18,7 +19,7 @@ yarn ### x402 / L402 — paid API fetch -These examples fetch a URL protected by an HTTP 402 payment wall. You need an [NWC](https://nwc.getalby.com) connection string. +These examples fetch a URL protected by an HTTP 402 payment wall. You need an [NWC](https://www.nwc.dev) connection string. ```bash NWC_URL="nostr+walletconnect://..." yarn 402 # auto-detects L402 or x402 @@ -27,6 +28,7 @@ NWC_URL="nostr+walletconnect://..." yarn l402 # L402 only ``` Override the default URL: + ```bash URL="https://your-402-endpoint.example.com" NWC_URL="nostr+walletconnect://..." yarn 402 ``` diff --git a/examples/zaps-nwc.js b/examples/zaps-nwc.js index d94cc8f..30fa9e5 100644 --- a/examples/zaps-nwc.js +++ b/examples/zaps-nwc.js @@ -5,9 +5,8 @@ import { hexToBytes } from "@noble/hashes/utils"; // your private key is required to sign zap request events const nostrPrivateKey = process.env.NOSTR_PRIVATE_KEY; + // NWC url will be used to pay the zap invoice. -// It can be created in advanced at nwc.getalby.com, -// or use webln.NostrWebLNProvider.withNewSecret() to generate a new one const nostrWalletConnectUrl = process.env.NWC_URL; if (!nostrPrivateKey || !nostrWalletConnectUrl) { @@ -15,9 +14,7 @@ if (!nostrPrivateKey || !nostrWalletConnectUrl) { } (async () => { - const nostrWeblnProvider = new NostrWebLNProvider({ - nostrWalletConnectUrl, - }); + const nostrWeblnProvider = new NostrWebLNProvider({ nostrWalletConnectUrl }); // or use nostrWeblnProvider.initNWC(); to get a new NWC url const nostrProvider = { getPublicKey: () => From 6b02c673bcc2d39fb8bcda8892d95636bac80aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Tue, 24 Mar 2026 11:20:05 +0100 Subject: [PATCH 22/24] fix: updated readme --- README.md | 61 ++++++++++++++++++++++++------------------------------- 1 file changed, 26 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 57ed346..86acdac 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ or for use without any build tools: ```html