Skip to content
Draft
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
32 changes: 31 additions & 1 deletion src/commands/fetch.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -67,6 +67,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 @@ -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
Expand Down
87 changes: 87 additions & 0 deletions src/test/fetch-dry-run.test.ts
Original file line number Diff line number Diff line change
@@ -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<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("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);
});
});
93 changes: 92 additions & 1 deletion src/tools/lightning/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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, {
Expand All @@ -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;
}
Loading