From 1750150f24ffe5ad52637181c1d105d9388937a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:10:14 +0200 Subject: [PATCH 1/4] feat: fetch --dry-run previews the price without paying Per payments-skill#28 review: prices listed by 402index.io can be missing, stale, or dynamic - the 402 challenge is the authoritative price at request time, but checking it previously meant hand-crafting a curl. --dry-run sends the request unpaid (no wallet needed) and reports the challenge: price in sats when a lightning invoice is offered (L402/MPP WWW-Authenticate, or a lightning-native x402 Payment-Required header), the raw challenge otherwise. Payment flags are rejected alongside it since a dry run never pays. Use case: comparing prices between providers before paying. Verified live against all three challenge shapes: native L402 (10 sats), bridged x402 via l402.space (19 sats), lightning-native x402 (15 sats). --- src/commands/fetch.ts | 32 +++++++++++- src/test/fetch-dry-run.test.ts | 87 +++++++++++++++++++++++++++++++ src/tools/lightning/fetch.ts | 93 +++++++++++++++++++++++++++++++++- 3 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 src/test/fetch-dry-run.test.ts diff --git a/src/commands/fetch.ts b/src/commands/fetch.ts index 4d7c60c..5e4108b 100644 --- a/src/commands/fetch.ts +++ b/src/commands/fetch.ts @@ -1,5 +1,5 @@ import { Command, InvalidArgumentError } from "commander"; -import { fetch402 } from "../tools/lightning/fetch.js"; +import { dryRun402, fetch402 } from "../tools/lightning/fetch.js"; import { getClient, handleError, output } from "../utils.js"; import { classifyRail } from "../amount.js"; @@ -67,6 +67,13 @@ export function registerFetch402Command(program: Command) { .option("-X, --method ", "HTTP method (GET, POST, etc.)") .option("-b, --body ", "Request body (JSON string)") .option("-H, --headers ", "Additional headers (JSON string)") + .option( + "--dry-run", + "Preview the price without paying: sends the request unpaid and reports " + + "the 402 challenge (price in sats when a lightning invoice is offered). " + + "Needs no wallet. Prices listed by directories can be stale or dynamic - " + + "this is the endpoint's actual price right now.", + ) .option( "--max-amount ", "Maximum amount to auto-pay per request. Aborts if the endpoint requests more. " + @@ -102,6 +109,29 @@ export function registerFetch402Command(program: Command) { ) .action(async (url, options) => { await handleError(async () => { + // A dry run never pays, so the payment flags are meaningless with it. + if (options.dryRun) { + if ( + options.maxAmount !== undefined || + options.currency || + options.unit || + options.network || + options.credentials + ) { + throw new Error( + "--dry-run never pays; drop --max-amount/--currency/--unit/--network/--credentials", + ); + } + const result = await dryRun402({ + url: url, + method: options.method, + body: options.body, + headers: options.headers ? JSON.parse(options.headers) : undefined, + }); + output(result); + return; + } + // A cap must state its denomination, like every other amount. For now // the only supported rail is BTC/sats over lightning — the cap is // inherently a sats spend limit — but it goes through the shared diff --git a/src/test/fetch-dry-run.test.ts b/src/test/fetch-dry-run.test.ts new file mode 100644 index 0000000..ccea12b --- /dev/null +++ b/src/test/fetch-dry-run.test.ts @@ -0,0 +1,87 @@ +import { describe, test, expect, vi, afterEach } from "vitest"; +import { dryRun402 } from "../tools/lightning/fetch.js"; + +// A real 100-sat BOLT-11 invoice (shared with lightning-tools.test.ts) so the +// price extraction exercises actual invoice decoding, not a mock. +const exampleInvoice = + "lnbc1u1p5hlrr8dqqnp4qwmtpr4p72ms7gnq3pkfk2876y2msvl33s3840dlp6xsv2w59dpscpp55utq6s8u5407namwt4jvhgsaf9fyszppjfwyxp7qsw6cyc8vxukqsp583usez9yhmkcavvvjz8cq56v3nglh2q37xkf4ufrgwxfrfjkm54s9qyysgqcqzp2xqyz5vqgtyysw64zt9sj6kfpqnekzwc37y2uyg0xdapgxqqth4uahff0x89sjfsvukjlllasg5dn05u2uha6qcvxz2y3ye5k7958qtes4pv4ggqtnjyky"; + +function stub402(headers: Record, body = "payment required") { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(body, { status: 402, headers })), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetch --dry-run price preview", () => { + test("reports the sats price from an L402 challenge without paying", async () => { + stub402({ + "www-authenticate": `L402 token="abc", invoice="${exampleInvoice}"`, + }); + + const result = await dryRun402({ url: "https://l402.example/api" }); + + expect(result.payment_required).toBe(true); + expect(result.amount_in_sats).toBe(100); + }); + + test("reports the sats price from an MPP Payment challenge", async () => { + stub402({ + "www-authenticate": `Payment realm="svc", method="lightning", intent="charge", invoice="${exampleInvoice}", amount="100", currency="sat"`, + }); + + const result = await dryRun402({ url: "https://mpp.example/api" }); + + expect(result.payment_required).toBe(true); + expect(result.amount_in_sats).toBe(100); + }); + + test("finds the invoice inside a base64 x402 Payment-Required header", async () => { + const x402Challenge = Buffer.from( + JSON.stringify({ + x402Version: 2, + accepts: [ + { + network: "bip122:000000000019d6689c085ae165831e93", + asset: "BTC", + extra: { paymentMethod: "lightning", invoice: exampleInvoice }, + }, + ], + }), + ).toString("base64"); + stub402({ "payment-required": x402Challenge }); + + const result = await dryRun402({ url: "https://x402ln.example/api" }); + + expect(result.payment_required).toBe(true); + expect(result.amount_in_sats).toBe(100); + }); + + test("surfaces the raw challenge when no lightning invoice is offered", async () => { + stub402({ + "www-authenticate": 'Payment realm="svc", method="tempo", intent="charge"', + }); + + const result = await dryRun402({ url: "https://tempo.example/api" }); + + expect(result.payment_required).toBe(true); + expect(result.amount_in_sats).toBeUndefined(); + expect(result.challenge).toContain('method="tempo"'); + }); + + test("reports payment_required false for a non-402 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("free content", { status: 200 })), + ); + + const result = await dryRun402({ url: "https://free.example/api" }); + + expect(result.payment_required).toBe(false); + expect(result.status).toBe(200); + }); +}); diff --git a/src/tools/lightning/fetch.ts b/src/tools/lightning/fetch.ts index bc7aeb9..f7a4a57 100644 --- a/src/tools/lightning/fetch.ts +++ b/src/tools/lightning/fetch.ts @@ -2,6 +2,7 @@ import { fetch402 as fetch402Lib, type PaymentCredentials, } from "@getalby/lightning-tools/402"; +import { Invoice } from "@getalby/lightning-tools"; import { NWCClient } from "@getalby/sdk"; const DEFAULT_MAX_AMOUNT_SATS = 5000; @@ -20,7 +21,7 @@ export interface Fetch402Params { credentials?: PaymentCredentials; } -export async function fetch402(client: NWCClient, params: Fetch402Params) { +function buildRequestOptions(params: Fetch402Params): RequestInit { const method = params.method?.toUpperCase(); const requestOptions: RequestInit = { method, @@ -43,6 +44,11 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) { requestOptions.headers = params.headers; } + return requestOptions; +} + +export async function fetch402(client: NWCClient, params: Fetch402Params) { + const requestOptions = buildRequestOptions(params); const maxAmountSats = params.maxAmountSats ?? DEFAULT_MAX_AMOUNT_SATS; const result = await fetch402Lib(params.url, requestOptions, { @@ -68,3 +74,88 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) { payment: result.payment, }; } + +export interface DryRun402Result { + url: string; + status: number; + payment_required: boolean; + /** Present when the 402 challenge offers a lightning invoice. */ + amount_in_sats?: number; + description?: string | null; + /** The raw challenge, for protocols/rails we can't price in sats. */ + challenge?: string; +} + +const BOLT11_PATTERN = /ln(?:bc|tb|bcrt|tbs)[0-9a-z]+/i; + +// Find the lightning invoice a 402 challenge offers, wherever the protocol +// puts it: L402 and MPP carry it in WWW-Authenticate (invoice="lnbc..."); +// lightning-native x402 embeds it in the base64 Payment-Required header (or +// the JSON body) as extra.invoice. +function extractLightningInvoice( + response: Response, + bodyText: string, +): string | null { + const wwwAuthenticate = response.headers.get("www-authenticate") ?? ""; + const headerMatch = wwwAuthenticate.match( + new RegExp(`invoice="(${BOLT11_PATTERN.source})"`, "i"), + ); + if (headerMatch) return headerMatch[1]; + + const paymentRequired = response.headers.get("payment-required"); + if (paymentRequired) { + try { + const decoded = Buffer.from(paymentRequired, "base64").toString("utf-8"); + const match = decoded.match(BOLT11_PATTERN); + if (match) return match[0]; + } catch { + // Not base64 - fall through to the body. + } + } + + return bodyText.match(BOLT11_PATTERN)?.[0] ?? null; +} + +/** + * Preview what a paid endpoint costs without paying: send the request unpaid + * and report the 402 challenge. Prices on 402index.io can be missing, stale, + * or dynamic - the challenge is the authoritative price at request time. Needs + * no wallet. + */ +export async function dryRun402(params: Fetch402Params) { + const response = await fetch(params.url, buildRequestOptions(params)); + const bodyText = await response.text(); + + if (response.status !== 402) { + return { + url: params.url, + status: response.status, + payment_required: false, + } satisfies DryRun402Result; + } + + const invoice = extractLightningInvoice(response, bodyText); + if (invoice) { + const { satoshi, description } = new Invoice({ pr: invoice }); + return { + url: params.url, + status: response.status, + payment_required: true, + amount_in_sats: satoshi, + description, + } satisfies DryRun402Result; + } + + // No lightning invoice offered (e.g. USDC-only x402 hit directly instead of + // through the l402.space bridge) - surface the raw challenge so the caller + // can still see the terms. + return { + url: params.url, + status: response.status, + payment_required: true, + challenge: + response.headers.get("www-authenticate") ?? + response.headers.get("payment-required") ?? + bodyText.slice(0, 2000), + } satisfies DryRun402Result; +} From 46cd43b95b806950f73c551e85650be8ab762b5c Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 22 Jul 2026 16:26:05 +0700 Subject: [PATCH 2/4] fix: silently ignore payment flags with --dry-run instead of erroring Co-Authored-By: Claude Fable 5 --- src/commands/fetch.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/commands/fetch.ts b/src/commands/fetch.ts index cef4376..bf9e777 100644 --- a/src/commands/fetch.ts +++ b/src/commands/fetch.ts @@ -158,20 +158,8 @@ export function registerFetch402Command(program: Command) { ) .action(async (url, options) => { await handleError(async () => { - // A dry run never pays, so the payment flags are meaningless with it. + // A dry run never pays, so the payment flags are simply ignored. if (options.dryRun) { - if ( - options.maxAmount !== undefined || - options.currency || - options.unit || - options.network || - options.credentials || - options.resume - ) { - throw new Error( - "--dry-run never pays; drop --max-amount/--currency/--unit/--network/--credentials/--resume", - ); - } const result = await dryRun402({ url: url, method: options.method, From d67ccf80f3be1c11802aaf7de1cef4c2a60f8b9b Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 22 Jul 2026 16:37:02 +0700 Subject: [PATCH 3/4] feat: suggest l402.space bridge when a 402 offers no lightning invoice The CLI can only pay lightning, so a challenge without a lightning invoice was dead weight as a raw blob - point at the bridge URL that makes the endpoint payable instead. Co-Authored-By: Claude Fable 5 --- src/test/fetch-dry-run.test.ts | 7 +++++-- src/tools/lightning/fetch.ts | 18 +++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/test/fetch-dry-run.test.ts b/src/test/fetch-dry-run.test.ts index ccea12b..6b91540 100644 --- a/src/test/fetch-dry-run.test.ts +++ b/src/test/fetch-dry-run.test.ts @@ -61,7 +61,7 @@ describe("fetch --dry-run price preview", () => { expect(result.amount_in_sats).toBe(100); }); - test("surfaces the raw challenge when no lightning invoice is offered", async () => { + test("suggests the l402.space bridge when no lightning invoice is offered", async () => { stub402({ "www-authenticate": 'Payment realm="svc", method="tempo", intent="charge"', }); @@ -70,7 +70,10 @@ describe("fetch --dry-run price preview", () => { expect(result.payment_required).toBe(true); expect(result.amount_in_sats).toBeUndefined(); - expect(result.challenge).toContain('method="tempo"'); + expect(result.message).toContain("no lightning invoice found"); + expect(result.message).toContain( + "https://l402.space/https%3A%2F%2Ftempo.example%2Fapi", + ); }); test("reports payment_required false for a non-402 response", async () => { diff --git a/src/tools/lightning/fetch.ts b/src/tools/lightning/fetch.ts index 7a0e3cb..6d5b59c 100644 --- a/src/tools/lightning/fetch.ts +++ b/src/tools/lightning/fetch.ts @@ -113,8 +113,8 @@ export interface DryRun402Result { /** Present when the 402 challenge offers a lightning invoice. */ amount_in_sats?: number; description?: string | null; - /** The raw challenge, for protocols/rails we can't price in sats. */ - challenge?: string; + /** Present when the challenge offers no lightning invoice. */ + message?: string; } const BOLT11_PATTERN = /ln(?:bc|tb|bcrt|tbs)[0-9a-z]+/i; @@ -177,17 +177,17 @@ export async function dryRun402(params: Fetch402Params) { } satisfies DryRun402Result; } - // No lightning invoice offered (e.g. USDC-only x402 hit directly instead of - // through the l402.space bridge) - surface the raw challenge so the caller - // can still see the terms. + // No lightning invoice offered (e.g. USDC-only x402) - this CLI pays + // lightning only, so point at the l402.space bridge, which settles the + // upstream and charges over lightning. return { url: params.url, status: response.status, payment_required: true, - challenge: - response.headers.get("www-authenticate") ?? - response.headers.get("payment-required") ?? - bodyText.slice(0, 2000), + message: + "no lightning invoice found in the 402 challenge - try fetching " + + "through the l402.space bridge instead: " + + `https://l402.space/${encodeURIComponent(params.url)}`, } satisfies DryRun402Result; } From ff7e19ca953c2e4b3d26e9382b6a56319596e283 Mon Sep 17 00:00:00 2001 From: Roland Bewick Date: Wed, 22 Jul 2026 16:46:47 +0700 Subject: [PATCH 4/4] fix: fall back to bridge suggestion when a matched invoice doesn't decode The BOLT11 pattern can match invoice-looking garbage in a challenge body; decoding it then threw and crashed the dry run. Co-Authored-By: Claude Fable 5 --- src/test/fetch-dry-run.test.ts | 12 ++++++++++++ src/tools/lightning/fetch.ts | 20 ++++++++++++-------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/test/fetch-dry-run.test.ts b/src/test/fetch-dry-run.test.ts index 6b91540..be9de93 100644 --- a/src/test/fetch-dry-run.test.ts +++ b/src/test/fetch-dry-run.test.ts @@ -76,6 +76,18 @@ describe("fetch --dry-run price preview", () => { ); }); + test("falls back to the bridge suggestion when the invoice doesn't decode", async () => { + stub402({ + "www-authenticate": 'L402 token="abc", invoice="lnbc1notarealinvoice00"', + }); + + const result = await dryRun402({ url: "https://broken.example/api" }); + + expect(result.payment_required).toBe(true); + expect(result.amount_in_sats).toBeUndefined(); + expect(result.message).toContain("no lightning invoice found"); + }); + test("reports payment_required false for a non-402 response", async () => { vi.stubGlobal( "fetch", diff --git a/src/tools/lightning/fetch.ts b/src/tools/lightning/fetch.ts index 6d5b59c..51af10c 100644 --- a/src/tools/lightning/fetch.ts +++ b/src/tools/lightning/fetch.ts @@ -167,14 +167,18 @@ export async function dryRun402(params: Fetch402Params) { const invoice = extractLightningInvoice(response, bodyText); if (invoice) { - const { satoshi, description } = new Invoice({ pr: invoice }); - return { - url: params.url, - status: response.status, - payment_required: true, - amount_in_sats: satoshi, - description, - } satisfies DryRun402Result; + // The pattern can match invoice-looking garbage; a challenge whose + // "invoice" doesn't decode offers no usable invoice - fall through. + try { + const { satoshi, description } = new Invoice({ pr: invoice }); + return { + url: params.url, + status: response.status, + payment_required: true, + amount_in_sats: satoshi, + description, + } satisfies DryRun402Result; + } catch {} } // No lightning invoice offered (e.g. USDC-only x402) - this CLI pays