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] 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; +}