From 1c061ea5b624f1f75f40f2801b7f4025f2141af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:26:02 +0200 Subject: [PATCH 1/3] feat: discover all protocols, not just lightning Drop the server-side payment_asset=BTC filter so discover returns L402, x402 and MPP services. Non-lightning services (e.g. x402/USDC) can be paid in sats by fetching them through the l402.space bridge. Also surface payment_network in results so callers can tell which rail a service settles on and decide whether the bridge is needed. Refs #26 --- src/commands/discover.ts | 3 ++- src/tools/lightning/discover.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/commands/discover.ts b/src/commands/discover.ts index f5f199c..8747e98 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). " + + "Non-lightning services can be paid in sats via the l402.space bridge - see fetch.", ) .option("-q, --query ", "Search query") .option( diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 024e7a5..71a7af1 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(); @@ -65,6 +66,7 @@ export async function discover(params: DiscoverParams) { description: s.description, url: s.url, protocol: s.protocol, + payment_network: s.payment_network, price_sats: s.price_sats, price_usd: s.price_usd, category: s.category, From 5e08857fb8cadf0cac65906bc784f9e1410a9c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:49:22 +0200 Subject: [PATCH 2/3] feat: transparently bridge x402/MPP through l402.space in fetch When lightning-tools hands back an unpaid 402 (e.g. a USDC-only x402 endpoint it can't settle over lightning), retry once through the l402.space bridge, which re-wraps the upstream as an L402 lightning challenge we can pay. Native L402 and lightning-payable x402/MPP are still paid directly and never touch the bridge - no flag, no manual URL encoding for the caller. Refs #26 --- src/commands/discover.ts | 2 +- src/test/fetch-bridge.test.ts | 60 +++++++++++++++++++++++++++++++++++ src/tools/lightning/fetch.ts | 52 ++++++++++++++++++++++-------- 3 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 src/test/fetch-bridge.test.ts diff --git a/src/commands/discover.ts b/src/commands/discover.ts index 8747e98..91a6a3a 100644 --- a/src/commands/discover.ts +++ b/src/commands/discover.ts @@ -7,7 +7,7 @@ export function registerDiscoverCommand(program: Command) { .command("discover") .description( "Search 402index.io for paid API services (L402, x402, MPP). " + - "Non-lightning services can be paid in sats via the l402.space bridge - see fetch.", + "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/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( From a0f713d867bfc47c69e5fc2a9e7b6408a9ac0343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:00:27 +0200 Subject: [PATCH 3/3] refactor: drop payment_network from discover output It was added so callers could decide whether to use the bridge, but fetch now bridges non-lightning services transparently, so the rail is an implementation detail. protocol already covers what a service is. --- src/tools/lightning/discover.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 71a7af1..509c886 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -66,7 +66,6 @@ export async function discover(params: DiscoverParams) { description: s.description, url: s.url, protocol: s.protocol, - payment_network: s.payment_network, price_sats: s.price_sats, price_usd: s.price_usd, category: s.category,