Skip to content

Commit 42d902c

Browse files
reneaaronrolznzclaude
authored
feat: fetch --dry-run previews the price without paying (#56)
* 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). * fix: silently ignore payment flags with --dry-run instead of erroring Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Roland Bewick <roland.bewick@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 48cbcea commit 42d902c

3 files changed

Lines changed: 222 additions & 2 deletions

File tree

src/commands/fetch.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { Command, InvalidArgumentError } from "commander";
2-
import { fetch402, type Fetch402Resume } from "../tools/lightning/fetch.js";
2+
import {
3+
dryRun402,
4+
fetch402,
5+
type Fetch402Resume,
6+
} from "../tools/lightning/fetch.js";
37
import { getClient, handleError, output } from "../utils.js";
48
import { classifyRail } from "../amount.js";
59

@@ -97,6 +101,13 @@ export function registerFetch402Command(program: Command) {
97101
.option("-X, --method <method>", "HTTP method (GET, POST, etc.)")
98102
.option("-b, --body <json>", "Request body (JSON string)")
99103
.option("-H, --headers <json>", "Additional headers (JSON string)")
104+
.option(
105+
"--dry-run",
106+
"Preview the price without paying: sends the request unpaid and reports " +
107+
"the 402 challenge (price in sats when a lightning invoice is offered). " +
108+
"Needs no wallet. Prices listed by directories can be stale or dynamic - " +
109+
"this is the endpoint's actual price right now.",
110+
)
100111
.option(
101112
"--max-amount <amount>",
102113
"Maximum amount to auto-pay per request. Aborts if the endpoint requests more. " +
@@ -147,6 +158,18 @@ export function registerFetch402Command(program: Command) {
147158
)
148159
.action(async (url, options) => {
149160
await handleError(async () => {
161+
// A dry run never pays, so the payment flags are simply ignored.
162+
if (options.dryRun) {
163+
const result = await dryRun402({
164+
url: url,
165+
method: options.method,
166+
body: options.body,
167+
headers: options.headers ? JSON.parse(options.headers) : undefined,
168+
});
169+
output(result);
170+
return;
171+
}
172+
150173
// A cap must state its denomination, like every other amount. For now
151174
// the only supported rail is BTC/sats over lightning — the cap is
152175
// inherently a sats spend limit — but it goes through the shared

src/test/fetch-dry-run.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { describe, test, expect, vi, afterEach } from "vitest";
2+
import { dryRun402 } from "../tools/lightning/fetch.js";
3+
4+
// A real 100-sat BOLT-11 invoice (shared with lightning-tools.test.ts) so the
5+
// price extraction exercises actual invoice decoding, not a mock.
6+
const exampleInvoice =
7+
"lnbc1u1p5hlrr8dqqnp4qwmtpr4p72ms7gnq3pkfk2876y2msvl33s3840dlp6xsv2w59dpscpp55utq6s8u5407namwt4jvhgsaf9fyszppjfwyxp7qsw6cyc8vxukqsp583usez9yhmkcavvvjz8cq56v3nglh2q37xkf4ufrgwxfrfjkm54s9qyysgqcqzp2xqyz5vqgtyysw64zt9sj6kfpqnekzwc37y2uyg0xdapgxqqth4uahff0x89sjfsvukjlllasg5dn05u2uha6qcvxz2y3ye5k7958qtes4pv4ggqtnjyky";
8+
9+
function stub402(headers: Record<string, string>, body = "payment required") {
10+
vi.stubGlobal(
11+
"fetch",
12+
vi.fn().mockResolvedValue(new Response(body, { status: 402, headers })),
13+
);
14+
}
15+
16+
afterEach(() => {
17+
vi.unstubAllGlobals();
18+
});
19+
20+
describe("fetch --dry-run price preview", () => {
21+
test("reports the sats price from an L402 challenge without paying", async () => {
22+
stub402({
23+
"www-authenticate": `L402 token="abc", invoice="${exampleInvoice}"`,
24+
});
25+
26+
const result = await dryRun402({ url: "https://l402.example/api" });
27+
28+
expect(result.payment_required).toBe(true);
29+
expect(result.amount_in_sats).toBe(100);
30+
});
31+
32+
test("reports the sats price from an MPP Payment challenge", async () => {
33+
stub402({
34+
"www-authenticate": `Payment realm="svc", method="lightning", intent="charge", invoice="${exampleInvoice}", amount="100", currency="sat"`,
35+
});
36+
37+
const result = await dryRun402({ url: "https://mpp.example/api" });
38+
39+
expect(result.payment_required).toBe(true);
40+
expect(result.amount_in_sats).toBe(100);
41+
});
42+
43+
test("finds the invoice inside a base64 x402 Payment-Required header", async () => {
44+
const x402Challenge = Buffer.from(
45+
JSON.stringify({
46+
x402Version: 2,
47+
accepts: [
48+
{
49+
network: "bip122:000000000019d6689c085ae165831e93",
50+
asset: "BTC",
51+
extra: { paymentMethod: "lightning", invoice: exampleInvoice },
52+
},
53+
],
54+
}),
55+
).toString("base64");
56+
stub402({ "payment-required": x402Challenge });
57+
58+
const result = await dryRun402({ url: "https://x402ln.example/api" });
59+
60+
expect(result.payment_required).toBe(true);
61+
expect(result.amount_in_sats).toBe(100);
62+
});
63+
64+
test("suggests the l402.space bridge when no lightning invoice is offered", async () => {
65+
stub402({
66+
"www-authenticate": 'Payment realm="svc", method="tempo", intent="charge"',
67+
});
68+
69+
const result = await dryRun402({ url: "https://tempo.example/api" });
70+
71+
expect(result.payment_required).toBe(true);
72+
expect(result.amount_in_sats).toBeUndefined();
73+
expect(result.message).toContain("no lightning invoice found");
74+
expect(result.message).toContain(
75+
"https://l402.space/https%3A%2F%2Ftempo.example%2Fapi",
76+
);
77+
});
78+
79+
test("falls back to the bridge suggestion when the invoice doesn't decode", async () => {
80+
stub402({
81+
"www-authenticate": 'L402 token="abc", invoice="lnbc1notarealinvoice00"',
82+
});
83+
84+
const result = await dryRun402({ url: "https://broken.example/api" });
85+
86+
expect(result.payment_required).toBe(true);
87+
expect(result.amount_in_sats).toBeUndefined();
88+
expect(result.message).toContain("no lightning invoice found");
89+
});
90+
91+
test("reports payment_required false for a non-402 response", async () => {
92+
vi.stubGlobal(
93+
"fetch",
94+
vi.fn().mockResolvedValue(new Response("free content", { status: 200 })),
95+
);
96+
97+
const result = await dryRun402({ url: "https://free.example/api" });
98+
99+
expect(result.payment_required).toBe(false);
100+
expect(result.status).toBe(200);
101+
});
102+
});

src/tools/lightning/fetch.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type PaymentCredentials,
66
type PendingPayment,
77
} from "@getalby/lightning-tools/402";
8+
import { Invoice } from "@getalby/lightning-tools";
89
import { NWCClient } from "@getalby/sdk";
910
import { DetailedError } from "../../utils.js";
1011

@@ -36,7 +37,7 @@ export interface Fetch402Params {
3637
resume?: Fetch402Resume;
3738
}
3839

39-
export async function fetch402(client: NWCClient, params: Fetch402Params) {
40+
function buildRequestOptions(params: Fetch402Params): RequestInit {
4041
const method = params.method?.toUpperCase();
4142
const requestOptions: RequestInit = {
4243
method,
@@ -59,6 +60,11 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) {
5960
requestOptions.headers = params.headers;
6061
}
6162

63+
return requestOptions;
64+
}
65+
66+
export async function fetch402(client: NWCClient, params: Fetch402Params) {
67+
const requestOptions = buildRequestOptions(params);
6268
const maxAmountSats = params.maxAmountSats ?? DEFAULT_MAX_AMOUNT_SATS;
6369

6470
let result;
@@ -100,6 +106,95 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) {
100106
};
101107
}
102108

109+
export interface DryRun402Result {
110+
url: string;
111+
status: number;
112+
payment_required: boolean;
113+
/** Present when the 402 challenge offers a lightning invoice. */
114+
amount_in_sats?: number;
115+
description?: string | null;
116+
/** Present when the challenge offers no lightning invoice. */
117+
message?: string;
118+
}
119+
120+
const BOLT11_PATTERN = /ln(?:bc|tb|bcrt|tbs)[0-9a-z]+/i;
121+
122+
// Find the lightning invoice a 402 challenge offers, wherever the protocol
123+
// puts it: L402 and MPP carry it in WWW-Authenticate (invoice="lnbc...");
124+
// lightning-native x402 embeds it in the base64 Payment-Required header (or
125+
// the JSON body) as extra.invoice.
126+
function extractLightningInvoice(
127+
response: Response,
128+
bodyText: string,
129+
): string | null {
130+
const wwwAuthenticate = response.headers.get("www-authenticate") ?? "";
131+
const headerMatch = wwwAuthenticate.match(
132+
new RegExp(`invoice="(${BOLT11_PATTERN.source})"`, "i"),
133+
);
134+
if (headerMatch) return headerMatch[1];
135+
136+
const paymentRequired = response.headers.get("payment-required");
137+
if (paymentRequired) {
138+
try {
139+
const decoded = Buffer.from(paymentRequired, "base64").toString("utf-8");
140+
const match = decoded.match(BOLT11_PATTERN);
141+
if (match) return match[0];
142+
} catch {
143+
// Not base64 - fall through to the body.
144+
}
145+
}
146+
147+
return bodyText.match(BOLT11_PATTERN)?.[0] ?? null;
148+
}
149+
150+
/**
151+
* Preview what a paid endpoint costs without paying: send the request unpaid
152+
* and report the 402 challenge. Prices on 402index.io can be missing, stale,
153+
* or dynamic - the challenge is the authoritative price at request time. Needs
154+
* no wallet.
155+
*/
156+
export async function dryRun402(params: Fetch402Params) {
157+
const response = await fetch(params.url, buildRequestOptions(params));
158+
const bodyText = await response.text();
159+
160+
if (response.status !== 402) {
161+
return {
162+
url: params.url,
163+
status: response.status,
164+
payment_required: false,
165+
} satisfies DryRun402Result;
166+
}
167+
168+
const invoice = extractLightningInvoice(response, bodyText);
169+
if (invoice) {
170+
// The pattern can match invoice-looking garbage; a challenge whose
171+
// "invoice" doesn't decode offers no usable invoice - fall through.
172+
try {
173+
const { satoshi, description } = new Invoice({ pr: invoice });
174+
return {
175+
url: params.url,
176+
status: response.status,
177+
payment_required: true,
178+
amount_in_sats: satoshi,
179+
description,
180+
} satisfies DryRun402Result;
181+
} catch {}
182+
}
183+
184+
// No lightning invoice offered (e.g. USDC-only x402) - this CLI pays
185+
// lightning only, so point at the l402.space bridge, which settles the
186+
// upstream and charges over lightning.
187+
return {
188+
url: params.url,
189+
status: response.status,
190+
payment_required: true,
191+
message:
192+
"no lightning invoice found in the 402 challenge - try fetching " +
193+
"through the l402.space bridge instead: " +
194+
`https://l402.space/${encodeURIComponent(params.url)}`,
195+
} satisfies DryRun402Result;
196+
}
197+
103198
/**
104199
* Convert the library's Fetch402InterruptedError into a structured CLI error.
105200
*

0 commit comments

Comments
 (0)