Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/commands/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Command, InvalidArgumentError } from "commander";
import { fetch402, type Fetch402Resume } from "../tools/lightning/fetch.js";
import {
dryRun402,
fetch402,
type Fetch402Resume,
} from "../tools/lightning/fetch.js";
import { getClient, handleError, output } from "../utils.js";
import { classifyRail } from "../amount.js";

Expand Down Expand Up @@ -97,6 +101,13 @@ export function registerFetch402Command(program: Command) {
.option("-X, --method <method>", "HTTP method (GET, POST, etc.)")
.option("-b, --body <json>", "Request body (JSON string)")
.option("-H, --headers <json>", "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 <amount>",
"Maximum amount to auto-pay per request. Aborts if the endpoint requests more. " +
Expand Down Expand Up @@ -147,6 +158,18 @@ export function registerFetch402Command(program: Command) {
)
.action(async (url, options) => {
await handleError(async () => {
// A dry run never pays, so the payment flags are simply ignored.
if (options.dryRun) {
const result = await dryRun402({
url: url,
method: options.method,
body: options.body,
headers: options.headers ? JSON.parse(options.headers) : undefined,
});
output(result);
return;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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
Expand Down
102 changes: 102 additions & 0 deletions src/test/fetch-dry-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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<string, string>, 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("suggests the l402.space bridge 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.message).toContain("no lightning invoice found");
expect(result.message).toContain(
"https://l402.space/https%3A%2F%2Ftempo.example%2Fapi",
);
});

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",
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);
});
});
97 changes: 96 additions & 1 deletion src/tools/lightning/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type PaymentCredentials,
type PendingPayment,
} from "@getalby/lightning-tools/402";
import { Invoice } from "@getalby/lightning-tools";
import { NWCClient } from "@getalby/sdk";
import { DetailedError } from "../../utils.js";

Expand Down Expand Up @@ -36,7 +37,7 @@ export interface Fetch402Params {
resume?: Fetch402Resume;
}

export async function fetch402(client: NWCClient, params: Fetch402Params) {
function buildRequestOptions(params: Fetch402Params): RequestInit {
const method = params.method?.toUpperCase();
const requestOptions: RequestInit = {
method,
Expand All @@ -59,6 +60,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;

let result;
Expand Down Expand Up @@ -100,6 +106,95 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) {
};
}

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;
/** Present when the challenge offers no lightning invoice. */
message?: 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();

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (response.status !== 402) {
return {
url: params.url,
status: response.status,
payment_required: false,
} satisfies DryRun402Result;
}

const invoice = extractLightningInvoice(response, bodyText);
if (invoice) {
// 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 {}
}
Comment thread
rolznz marked this conversation as resolved.

// 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,
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;
}

/**
* Convert the library's Fetch402InterruptedError into a structured CLI error.
*
Expand Down
Loading