diff --git a/src/commands/discover.ts b/src/commands/discover.ts index f5f199c..91a6a3a 100644 --- a/src/commands/discover.ts +++ b/src/commands/discover.ts @@ -6,7 +6,8 @@ export function registerDiscoverCommand(program: Command) { program .command("discover") .description( - "Search 402index.io for paid API services that accept bitcoin/lightning", + "Search 402index.io for paid API services (L402, x402, MPP). " + + "All are payable in sats with the fetch command.", ) .option("-q, --query ", "Search query") .option( diff --git a/src/test/fetch-bridge.test.ts b/src/test/fetch-bridge.test.ts new file mode 100644 index 0000000..21eb8b4 --- /dev/null +++ b/src/test/fetch-bridge.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import type { NWCClient } from "@getalby/sdk"; + +// Mock the underlying protocol handler so we can drive the wrapper's decision +// logic: lightning-tools already pays L402/MPP/lightning-x402 directly and hands +// back an unpaid 402 for anything it can't settle over lightning (e.g. USDC-only +// x402). We assert our wrapper transparently retries those through l402.space. +const fetch402Lib = vi.fn(); +vi.mock("@getalby/lightning-tools/402", () => ({ + fetch402: (...args: unknown[]) => fetch402Lib(...args), +})); + +const { fetch402 } = await import("../tools/lightning/fetch.js"); + +const fakeClient = {} as NWCClient; + +beforeEach(() => { + fetch402Lib.mockReset(); +}); + +describe("fetch402 l402.space bridge fallback", () => { + test("retries through l402.space when a direct fetch returns 402", async () => { + fetch402Lib + .mockResolvedValueOnce(new Response("nope", { status: 402 })) + .mockResolvedValueOnce(new Response("paid content", { status: 200 })); + + const result = await fetch402(fakeClient, { + url: "https://x402.example/api", + }); + + expect(result.content).toBe("paid content"); + expect(fetch402Lib).toHaveBeenCalledTimes(2); + expect(fetch402Lib.mock.calls[0][0]).toBe("https://x402.example/api"); + expect(fetch402Lib.mock.calls[1][0]).toBe( + "https://l402.space/" + encodeURIComponent("https://x402.example/api"), + ); + }); + + test("pays directly without the bridge when the first fetch succeeds", async () => { + fetch402Lib.mockResolvedValueOnce( + new Response("direct content", { status: 200 }), + ); + + const result = await fetch402(fakeClient, { + url: "https://l402.example/api", + }); + + expect(result.content).toBe("direct content"); + expect(fetch402Lib).toHaveBeenCalledTimes(1); + }); + + test("does not double-bridge a url already pointing at l402.space", async () => { + fetch402Lib.mockResolvedValueOnce(new Response("nope", { status: 402 })); + + await expect( + fetch402(fakeClient, { url: "https://l402.space/whatever" }), + ).rejects.toThrow("non-OK status: 402"); + expect(fetch402Lib).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 024e7a5..509c886 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -15,8 +15,9 @@ export async function discover(params: DiscoverParams) { if (params.health) url.searchParams.set("health", params.health); if (params.sort) url.searchParams.set("sort", params.sort); - // Filter to BTC (lightning) services server-side - url.searchParams.set("payment_asset", "BTC"); + // No payment_asset filter: return services across all protocols (L402, x402, + // MPP). Non-lightning services (e.g. x402/USDC) can still be paid in sats by + // fetching them through the l402.space bridge - see the fetch command. url.searchParams.set("limit", String(requestedLimit)); const controller = new AbortController(); diff --git a/src/tools/lightning/fetch.ts b/src/tools/lightning/fetch.ts index 6096c48..0e3e7bc 100644 --- a/src/tools/lightning/fetch.ts +++ b/src/tools/lightning/fetch.ts @@ -3,6 +3,19 @@ import { NWCClient } from "@getalby/sdk"; const DEFAULT_MAX_AMOUNT_SATS = 5000; +// Non-lightning paid endpoints (e.g. USDC-only x402) can't be settled from a +// lightning wallet directly, so lightning-tools hands their 402 back unpaid. +// The l402.space bridge re-wraps any 402-gated upstream as an L402 (lightning) +// challenge and settles the upstream cost on our behalf - we transparently +// retry through it so callers only ever need a lightning balance. Native L402 +// (and lightning-payable x402/MPP) is paid directly and never touches the +// bridge. +const L402_SPACE_BRIDGE = "https://l402.space/"; + +function bridgeUrl(url: string): string { + return `${L402_SPACE_BRIDGE}${encodeURIComponent(url)}`; +} + export interface Fetch402Params { url: string; method?: string; @@ -13,27 +26,40 @@ export interface Fetch402Params { export async function fetch402(client: NWCClient, params: Fetch402Params) { const method = params.method?.toUpperCase(); - const requestOptions: RequestInit = { - method, - }; - if (method && method !== "GET" && method !== "HEAD") { - requestOptions.body = params.body; - requestOptions.headers = { - "Content-Type": "application/json", - ...params.headers, - }; - } else if (params.headers) { - requestOptions.headers = params.headers; - } + // fetch402Lib mutates the RequestInit it's given (headers, cache, mode), so + // build a fresh one per attempt to keep the bridge retry clean. + const buildRequestOptions = (): RequestInit => { + const requestOptions: RequestInit = { method }; + if (method && method !== "GET" && method !== "HEAD") { + requestOptions.body = params.body; + requestOptions.headers = { + "Content-Type": "application/json", + ...params.headers, + }; + } else if (params.headers) { + requestOptions.headers = params.headers; + } + return requestOptions; + }; const maxAmountSats = params.maxAmountSats ?? DEFAULT_MAX_AMOUNT_SATS; - const result = await fetch402Lib(params.url, requestOptions, { + let result = await fetch402Lib(params.url, buildRequestOptions(), { wallet: client, maxAmount: maxAmountSats, }); + // A 402 here means lightning-tools couldn't satisfy the challenge over + // lightning and handed the response back. Retry once through the l402.space + // bridge, which converts it to an L402 lightning challenge we can pay. + if (result.status === 402 && !params.url.startsWith(L402_SPACE_BRIDGE)) { + result = await fetch402Lib(bridgeUrl(params.url), buildRequestOptions(), { + wallet: client, + maxAmount: maxAmountSats, + }); + } + const responseContent = await result.text(); if (!result.ok) { throw new Error(