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/9] 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/9] 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/9] 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, From 558d956819817046fa1d2f921a53b8e8ab9acda6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:50:16 +0200 Subject: [PATCH 4/9] refactor: bridge non-lightning services in discover, not fetch Address PR feedback (rolznz): the transparent bridge retry in fetch was hidden from callers, which breaks macaroon/x402 token reuse for follow-up calls (js-lightning-tools#328) since the token would belong to the bridge URL, not the original. Instead, wrap non-lightning discover results (x402 on Base/Stellar/EVM) in an explicit l402.space bridge URL up front. The URL a caller fetches is now visible and consistent across follow-up requests, and fetch pays it as a plain L402 endpoint with no per-request redirection. L402/MPP and x402 on the Lightning network keep their native URL. Also surfaces payment_network in the output so callers see the rail. - discover.ts: wrap non-lightning URLs; revert fetch.ts to master - replace fetch-bridge.test.ts with discover-bridge.test.ts --- src/test/discover-bridge.test.ts | 106 +++++++++++++++++++++++++++++++ src/test/fetch-bridge.test.ts | 60 ----------------- src/tools/lightning/discover.ts | 36 +++++++++-- src/tools/lightning/fetch.ts | 52 ++++----------- 4 files changed, 151 insertions(+), 103 deletions(-) create mode 100644 src/test/discover-bridge.test.ts delete mode 100644 src/test/fetch-bridge.test.ts diff --git a/src/test/discover-bridge.test.ts b/src/test/discover-bridge.test.ts new file mode 100644 index 0000000..53e830c --- /dev/null +++ b/src/test/discover-bridge.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect, vi, afterEach } from "vitest"; +import { discover } from "../tools/lightning/discover.js"; + +// discover() calls global fetch against 402index.io. We stub it so we can drive +// the URL-wrapping logic: non-lightning services (e.g. x402 on Base/Stellar) +// come back with an explicit l402.space bridge URL, while natively +// lightning-payable services (L402, MPP, or x402 on the Lightning network) keep +// their own URL. +function stubIndexResponse( + services: Array<{ url: string; protocol: string; payment_network: string }>, +) { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + services: services.map((s) => ({ + name: "svc", + description: "", + url: s.url, + protocol: s.protocol, + price_sats: null, + price_usd: null, + payment_network: s.payment_network, + category: "", + provider: "", + health_status: "healthy", + reliability_score: null, + latency_p50_ms: null, + http_method: "GET", + })), + total: services.length, + limit: 10, + offset: 0, + }), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +const bridged = (url: string) => + "https://l402.space/" + encodeURIComponent(url); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("discover l402.space bridge wrapping", () => { + test("wraps non-lightning services (x402 on Base/Stellar/EVM) in the bridge URL", async () => { + stubIndexResponse([ + { url: "https://a.example/api", protocol: "x402", payment_network: "Base" }, + { + url: "https://b.example/api", + protocol: "x402", + payment_network: "eip155:8453", + }, + { + url: "https://c.example/api", + protocol: "x402", + payment_network: "stellar", + }, + ]); + + const result = await discover({}); + + expect(result.services.map((s) => s.url)).toEqual([ + bridged("https://a.example/api"), + bridged("https://b.example/api"), + bridged("https://c.example/api"), + ]); + }); + + test("leaves natively lightning-payable services unwrapped", async () => { + stubIndexResponse([ + { + url: "https://l402.example/api", + protocol: "L402", + payment_network: "Lightning", + }, + { url: "https://mpp.example/api", protocol: "MPP", payment_network: "" }, + { + url: "https://x402ln.example/api", + protocol: "x402", + payment_network: "Lightning", + }, + ]); + + const result = await discover({}); + + expect(result.services.map((s) => s.url)).toEqual([ + "https://l402.example/api", + "https://mpp.example/api", + "https://x402ln.example/api", + ]); + }); + + test("surfaces payment_network so callers see which rail they are paying", async () => { + stubIndexResponse([ + { url: "https://a.example/api", protocol: "x402", payment_network: "Base" }, + ]); + + const result = await discover({}); + + expect(result.services[0].payment_network).toBe("Base"); + }); +}); diff --git a/src/test/fetch-bridge.test.ts b/src/test/fetch-bridge.test.ts deleted file mode 100644 index 21eb8b4..0000000 --- a/src/test/fetch-bridge.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -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 509c886..94de21e 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -6,6 +6,31 @@ export interface DiscoverParams { limit?: number; } +// The l402.space bridge re-wraps any 402-gated upstream as an L402 (lightning) +// challenge, so a lightning wallet can pay endpoints it couldn't settle +// natively (e.g. USDC-only x402). We wrap non-lightning discover results in the +// bridge URL up front so the URL a caller fetches is explicit - `fetch` pays it +// as a normal L402 endpoint, with no hidden per-request redirection. +const L402_SPACE_BRIDGE = "https://l402.space/"; + +function bridgeUrl(url: string): string { + return `${L402_SPACE_BRIDGE}${encodeURIComponent(url)}`; +} + +// L402 and MPP settle over lightning by definition; x402 does only when its +// payment network is Lightning. Everything else (Base, Stellar, EVM chains, ...) +// needs the bridge to be payable from a lightning wallet. +function isLightningNative( + protocol: string, + paymentNetwork: string | null, +): boolean { + return ( + protocol === "L402" || + protocol === "MPP" || + (paymentNetwork ?? "").toLowerCase() === "lightning" + ); +} + export async function discover(params: DiscoverParams) { const url = new URL("https://402index.io/api/v1/services"); const requestedLimit = params.limit ?? 10; @@ -16,8 +41,8 @@ export async function discover(params: DiscoverParams) { if (params.sort) url.searchParams.set("sort", params.sort); // 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. + // MPP). Non-lightning results are returned with a bridged l402.space URL (see + // below) so they stay payable in sats via the fetch command. url.searchParams.set("limit", String(requestedLimit)); const controller = new AbortController(); @@ -47,7 +72,7 @@ export async function discover(params: DiscoverParams) { protocol: string; price_sats: number | null; price_usd: number | null; - payment_network: string; + payment_network: string | null; category: string; provider: string; health_status: string; @@ -64,8 +89,11 @@ export async function discover(params: DiscoverParams) { services: data.services.map((s) => ({ name: s.name, description: s.description, - url: s.url, + url: isLightningNative(s.protocol, s.payment_network) + ? s.url + : bridgeUrl(s.url), protocol: s.protocol, + payment_network: s.payment_network, price_sats: s.price_sats, price_usd: s.price_usd, category: s.category, diff --git a/src/tools/lightning/fetch.ts b/src/tools/lightning/fetch.ts index 0e3e7bc..6096c48 100644 --- a/src/tools/lightning/fetch.ts +++ b/src/tools/lightning/fetch.ts @@ -3,19 +3,6 @@ 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; @@ -26,40 +13,27 @@ export interface Fetch402Params { export async function fetch402(client: NWCClient, params: Fetch402Params) { const method = params.method?.toUpperCase(); - - // 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 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; + } + const maxAmountSats = params.maxAmountSats ?? DEFAULT_MAX_AMOUNT_SATS; - let result = await fetch402Lib(params.url, buildRequestOptions(), { + const result = await fetch402Lib(params.url, requestOptions, { 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 538ef90e6c1f5f2e8df1cd0c209717ca7ad7b3ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:58:14 +0200 Subject: [PATCH 5/9] fix: bridge by rail, not protocol - MPP is not lightning MPP (like x402) is payment-network agnostic: the index lists MPP services on Stripe/USD and with no declared rail, none of which settle over lightning. The previous predicate treated MPP as natively lightning and left those unwrapped, so they'd fail to pay from a lightning wallet. Decide by payment_network instead: native only when the rail is Lightning (handling multi-rail values like "Lightning, Base"), with L402 kept native by definition even when the index reports no network. Validated against 2000 live 402index.io services: all x402/MPP on Base/eip155/stellar/ Solana/Polygon/Stripe/None bridge; L402 (any network) and x402-on-Lightning stay native; zero L402-on-non-lightning anomalies. --- src/test/discover-bridge.test.ts | 30 ++++++++++++++++++++++++++---- src/tools/lightning/discover.ts | 19 +++++++++++-------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/test/discover-bridge.test.ts b/src/test/discover-bridge.test.ts index 53e830c..23893f4 100644 --- a/src/test/discover-bridge.test.ts +++ b/src/test/discover-bridge.test.ts @@ -7,7 +7,11 @@ import { discover } from "../tools/lightning/discover.js"; // lightning-payable services (L402, MPP, or x402 on the Lightning network) keep // their own URL. function stubIndexResponse( - services: Array<{ url: string; protocol: string; payment_network: string }>, + services: Array<{ + url: string; + protocol: string; + payment_network: string | null; + }>, ) { const fetchMock = vi.fn().mockResolvedValue( new Response( @@ -46,7 +50,10 @@ afterEach(() => { }); describe("discover l402.space bridge wrapping", () => { - test("wraps non-lightning services (x402 on Base/Stellar/EVM) in the bridge URL", async () => { + test("wraps every non-lightning rail in the bridge URL (x402 and MPP alike)", async () => { + // MPP and x402 are payment-network agnostic - USDC on Base/Stellar/EVM, + // USD via Stripe, or no declared rail - so none of these settle over + // lightning and all must be bridged. stubIndexResponse([ { url: "https://a.example/api", protocol: "x402", payment_network: "Base" }, { @@ -59,6 +66,9 @@ describe("discover l402.space bridge wrapping", () => { protocol: "x402", payment_network: "stellar", }, + { url: "https://d.example/api", protocol: "MPP", payment_network: "Stripe" }, + { url: "https://e.example/api", protocol: "MPP", payment_network: null }, + { url: "https://f.example/api", protocol: "x402", payment_network: null }, ]); const result = await discover({}); @@ -67,30 +77,42 @@ describe("discover l402.space bridge wrapping", () => { bridged("https://a.example/api"), bridged("https://b.example/api"), bridged("https://c.example/api"), + bridged("https://d.example/api"), + bridged("https://e.example/api"), + bridged("https://f.example/api"), ]); }); test("leaves natively lightning-payable services unwrapped", async () => { stubIndexResponse([ + // L402 is lightning by definition, even when the index reports no network. { url: "https://l402.example/api", protocol: "L402", payment_network: "Lightning", }, - { url: "https://mpp.example/api", protocol: "MPP", payment_network: "" }, + { url: "https://l402null.example/api", protocol: "L402", payment_network: null }, + // x402 does settle over lightning when its rail is Lightning... { url: "https://x402ln.example/api", protocol: "x402", payment_network: "Lightning", }, + // ...including when Lightning is one of several listed rails. + { + url: "https://multi.example/api", + protocol: "x402", + payment_network: "Lightning, Base", + }, ]); const result = await discover({}); expect(result.services.map((s) => s.url)).toEqual([ "https://l402.example/api", - "https://mpp.example/api", + "https://l402null.example/api", "https://x402ln.example/api", + "https://multi.example/api", ]); }); diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 94de21e..83540ff 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -17,18 +17,21 @@ function bridgeUrl(url: string): string { return `${L402_SPACE_BRIDGE}${encodeURIComponent(url)}`; } -// L402 and MPP settle over lightning by definition; x402 does only when its -// payment network is Lightning. Everything else (Base, Stellar, EVM chains, ...) -// needs the bridge to be payable from a lightning wallet. +// What's payable from a lightning wallet is decided by the rail, not the +// protocol: x402 and MPP are payment-network agnostic (USDC on Base, USD via +// Stripe, ...), so a lightning wallet can only settle them when their network +// is Lightning. L402 is the exception - it's lightning by definition, so it's +// always native even when the index reports no explicit network. Everything +// else (Base, Stellar, Solana, EVM chains, Stripe, ...) needs the bridge. function isLightningNative( protocol: string, paymentNetwork: string | null, ): boolean { - return ( - protocol === "L402" || - protocol === "MPP" || - (paymentNetwork ?? "").toLowerCase() === "lightning" - ); + if (protocol === "L402") return true; + // payment_network can list several rails, e.g. "Base, Solana". + return (paymentNetwork ?? "") + .split(",") + .some((rail) => rail.trim().toLowerCase() === "lightning"); } export async function discover(params: DiscoverParams) { From 8956ffa4e9d4861ac37162bb451963b5b7250fae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:04:24 +0200 Subject: [PATCH 6/9] fix: only bridge rails l402.space can actually settle Bridging a Stripe/USD or Stellar endpoint is pointless - the bridge can't pay those upstreams, so the wrapped URL would just 402. Whitelist the rails l402.space reports as funded (/api/info fundedNetworks: base, solana, tempo, lightning) instead of bridging everything non-lightning. Three-way decision per service: - native (pay directly): L402, or any rail == lightning - bridged: rail in {base, solana, tempo} (eip155:8453 aliased to base; "Base, Solana" multi-rail supported) - unwrapped: stellar, polygon, stripe, testnets (Base Sepolia != base), unknown/None - not payable from a lightning wallet, left as-is Validated the bucketing against 2000 live 402index.io services. --- src/test/discover-bridge.test.ts | 54 ++++++++++++++++++++----- src/tools/lightning/discover.ts | 68 ++++++++++++++++++++++---------- 2 files changed, 91 insertions(+), 31 deletions(-) diff --git a/src/test/discover-bridge.test.ts b/src/test/discover-bridge.test.ts index 23893f4..f880d7c 100644 --- a/src/test/discover-bridge.test.ts +++ b/src/test/discover-bridge.test.ts @@ -50,25 +50,25 @@ afterEach(() => { }); describe("discover l402.space bridge wrapping", () => { - test("wraps every non-lightning rail in the bridge URL (x402 and MPP alike)", async () => { - // MPP and x402 are payment-network agnostic - USDC on Base/Stellar/EVM, - // USD via Stripe, or no declared rail - so none of these settle over - // lightning and all must be bridged. + test("wraps services on a bridge-funded rail (base/solana/tempo, incl. eip155:8453 alias)", async () => { stubIndexResponse([ { url: "https://a.example/api", protocol: "x402", payment_network: "Base" }, { url: "https://b.example/api", protocol: "x402", - payment_network: "eip155:8453", + payment_network: "eip155:8453", // CAIP-2 for Base mainnet }, { url: "https://c.example/api", protocol: "x402", - payment_network: "stellar", + payment_network: "Solana", }, - { url: "https://d.example/api", protocol: "MPP", payment_network: "Stripe" }, - { url: "https://e.example/api", protocol: "MPP", payment_network: null }, - { url: "https://f.example/api", protocol: "x402", payment_network: null }, + { + url: "https://d.example/api", + protocol: "x402", + payment_network: "Base, Solana", + }, + { url: "https://e.example/api", protocol: "MPP", payment_network: "Tempo" }, ]); const result = await discover({}); @@ -79,7 +79,41 @@ describe("discover l402.space bridge wrapping", () => { bridged("https://c.example/api"), bridged("https://d.example/api"), bridged("https://e.example/api"), - bridged("https://f.example/api"), + ]); + }); + + test("leaves rails the bridge can't settle unwrapped (stellar/polygon/stripe/testnet/none)", async () => { + // l402.space only funds base/solana/tempo/lightning, so these aren't + // payable from a lightning wallet at all - wrapping them would just hand + // back a bridge URL that 402s. Note "Base Sepolia" must NOT match "base". + stubIndexResponse([ + { + url: "https://a.example/api", + protocol: "x402", + payment_network: "stellar", + }, + { + url: "https://b.example/api", + protocol: "x402", + payment_network: "Polygon", + }, + { url: "https://c.example/api", protocol: "MPP", payment_network: "Stripe" }, + { + url: "https://d.example/api", + protocol: "x402", + payment_network: "Base Sepolia", + }, + { url: "https://e.example/api", protocol: "MPP", payment_network: null }, + ]); + + const result = await discover({}); + + expect(result.services.map((s) => s.url)).toEqual([ + "https://a.example/api", + "https://b.example/api", + "https://c.example/api", + "https://d.example/api", + "https://e.example/api", ]); }); diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 83540ff..50876b2 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -6,32 +6,55 @@ export interface DiscoverParams { limit?: number; } -// The l402.space bridge re-wraps any 402-gated upstream as an L402 (lightning) -// challenge, so a lightning wallet can pay endpoints it couldn't settle -// natively (e.g. USDC-only x402). We wrap non-lightning discover results in the -// bridge URL up front so the URL a caller fetches is explicit - `fetch` pays it -// as a normal L402 endpoint, with no hidden per-request redirection. +// The l402.space bridge re-wraps a 402-gated upstream as an L402 (lightning) +// challenge and settles the upstream on our behalf, so a lightning wallet can +// pay endpoints it couldn't settle natively (e.g. USDC-only x402 on Base). We +// wrap bridgeable discover results in the bridge URL up front so the URL a +// caller fetches is explicit - `fetch` pays it as a normal L402 endpoint, with +// no hidden per-request redirection. const L402_SPACE_BRIDGE = "https://l402.space/"; function bridgeUrl(url: string): string { return `${L402_SPACE_BRIDGE}${encodeURIComponent(url)}`; } -// What's payable from a lightning wallet is decided by the rail, not the -// protocol: x402 and MPP are payment-network agnostic (USDC on Base, USD via -// Stripe, ...), so a lightning wallet can only settle them when their network -// is Lightning. L402 is the exception - it's lightning by definition, so it's -// always native even when the index reports no explicit network. Everything -// else (Base, Stellar, Solana, EVM chains, Stripe, ...) needs the bridge. +// The bridge can only settle upstreams on the rails it has funded wallets for - +// l402.space/api/info reports these as "base", "solana", "tempo", "lightning". +// "lightning" is handled as native (paid directly), so these are the extra +// rails the bridge unlocks. Assets follow the rail (USDC on base/solana, TIP-20 +// on tempo). Rails outside this set (Stellar, Polygon, Stripe, testnets, ...) +// aren't payable from a lightning wallet at all - we leave those unwrapped. +const BRIDGE_FUNDED_NETWORKS = new Set(["base", "solana", "tempo"]); + +// The index reports some rails as CAIP-2 chain ids; normalize the ones that map +// onto a funded network (eip155:8453 is Base mainnet). Testnets like Base +// Sepolia (eip155:84532 / "Base Sepolia") intentionally don't match. +const NETWORK_ALIASES: Record = { "eip155:8453": "base" }; + +// payment_network can list several rails, e.g. "Base, Solana". +function paymentRails(paymentNetwork: string | null): string[] { + return (paymentNetwork ?? "").split(",").map((rail) => { + const normalized = rail.trim().toLowerCase(); + return NETWORK_ALIASES[normalized] ?? normalized; + }); +} + +// L402 is lightning by definition (native even when the index reports no +// network); x402/MPP are rail-agnostic and only native when their rail is +// Lightning. function isLightningNative( protocol: string, paymentNetwork: string | null, ): boolean { - if (protocol === "L402") return true; - // payment_network can list several rails, e.g. "Base, Solana". - return (paymentNetwork ?? "") - .split(",") - .some((rail) => rail.trim().toLowerCase() === "lightning"); + return ( + protocol === "L402" || paymentRails(paymentNetwork).includes("lightning") + ); +} + +function isBridgeable(paymentNetwork: string | null): boolean { + return paymentRails(paymentNetwork).some((rail) => + BRIDGE_FUNDED_NETWORKS.has(rail), + ); } export async function discover(params: DiscoverParams) { @@ -44,8 +67,9 @@ export async function discover(params: DiscoverParams) { if (params.sort) url.searchParams.set("sort", params.sort); // No payment_asset filter: return services across all protocols (L402, x402, - // MPP). Non-lightning results are returned with a bridged l402.space URL (see - // below) so they stay payable in sats via the fetch command. + // MPP). Results on a bridge-funded rail are returned with a bridged + // l402.space URL (see below) so they stay payable in sats via the fetch + // command; unsupported rails keep their own URL. url.searchParams.set("limit", String(requestedLimit)); const controller = new AbortController(); @@ -92,9 +116,11 @@ export async function discover(params: DiscoverParams) { services: data.services.map((s) => ({ name: s.name, description: s.description, - url: isLightningNative(s.protocol, s.payment_network) - ? s.url - : bridgeUrl(s.url), + url: + isLightningNative(s.protocol, s.payment_network) || + !isBridgeable(s.payment_network) + ? s.url + : bridgeUrl(s.url), protocol: s.protocol, payment_network: s.payment_network, price_sats: s.price_sats, From c49ca6eb2b6c04df795c45409026bcc8745df1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:01:16 +0200 Subject: [PATCH 7/9] feat: discover returns only payable services, pre-wrapped Filter out services on rails the wallet can't reach (not native lightning and not a bridge-funded rail), so every discover result is payable in sats by fetching its url as-is - agents never need to reason about rails or the bridge. Native lightning keeps its own url; bridge-funded rails (Base/Solana/Tempo) come back already wrapped in l402.space. The index can't filter by rail server-side (payment_network is ignored; payment_asset can't tell USDC-on-Base from USDC-on-Stellar), so we filter client-side and over-fetch 2x (capped at the index's 200/page) to still return up to the requested limit. Verified live: a 20-result request comes back with 20 payable services, no Stellar/Polygon/Stripe leaking through. --- src/test/discover-bridge.test.ts | 36 +++++++++++++++++++++------- src/tools/lightning/discover.ts | 40 +++++++++++++++++++++----------- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/src/test/discover-bridge.test.ts b/src/test/discover-bridge.test.ts index f880d7c..460f393 100644 --- a/src/test/discover-bridge.test.ts +++ b/src/test/discover-bridge.test.ts @@ -82,10 +82,10 @@ describe("discover l402.space bridge wrapping", () => { ]); }); - test("leaves rails the bridge can't settle unwrapped (stellar/polygon/stripe/testnet/none)", async () => { + test("drops services on rails the bridge can't settle (stellar/polygon/stripe/testnet/none)", async () => { // l402.space only funds base/solana/tempo/lightning, so these aren't - // payable from a lightning wallet at all - wrapping them would just hand - // back a bridge URL that 402s. Note "Base Sepolia" must NOT match "base". + // payable from a lightning wallet at all. discover must never surface a + // service the wallet can't pay. Note "Base Sepolia" must NOT match "base". stubIndexResponse([ { url: "https://a.example/api", @@ -108,13 +108,33 @@ describe("discover l402.space bridge wrapping", () => { const result = await discover({}); + expect(result.services).toEqual([]); + expect(result.total).toBe(0); + }); + + test("returns only the payable services from a mixed page and reports their count", async () => { + stubIndexResponse([ + { + url: "https://ln.example/api", + protocol: "L402", + payment_network: "Lightning", + }, + { + url: "https://stellar.example/api", + protocol: "x402", + payment_network: "stellar", + }, + { url: "https://base.example/api", protocol: "x402", payment_network: "Base" }, + { url: "https://stripe.example/api", protocol: "MPP", payment_network: "Stripe" }, + ]); + + const result = await discover({}); + expect(result.services.map((s) => s.url)).toEqual([ - "https://a.example/api", - "https://b.example/api", - "https://c.example/api", - "https://d.example/api", - "https://e.example/api", + "https://ln.example/api", + bridged("https://base.example/api"), ]); + expect(result.total).toBe(2); }); test("leaves natively lightning-payable services unwrapped", async () => { diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 50876b2..56db03c 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -66,11 +66,14 @@ export async function discover(params: DiscoverParams) { if (params.health) url.searchParams.set("health", params.health); if (params.sort) url.searchParams.set("sort", params.sort); - // No payment_asset filter: return services across all protocols (L402, x402, - // MPP). Results on a bridge-funded rail are returned with a bridged - // l402.space URL (see below) so they stay payable in sats via the fetch - // command; unsupported rails keep their own URL. - url.searchParams.set("limit", String(requestedLimit)); + // We return services across all protocols (L402, x402, MPP) but drop any the + // wallet can't reach (rails the bridge doesn't fund), so every result is + // payable in sats via fetch. The index can't filter by rail server-side + // (only by payment_asset, which can't tell USDC-on-Base apart from + // USDC-on-Stellar), so we filter below and over-fetch a margin to still + // return up to requestedLimit payable results. The index caps a page at 200. + const fetchLimit = Math.min(200, requestedLimit * 2); + url.searchParams.set("limit", String(fetchLimit)); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); @@ -112,15 +115,21 @@ export async function discover(params: DiscoverParams) { offset: number; }; - return { - services: data.services.map((s) => ({ + const payable = data.services + .filter( + (s) => + isLightningNative(s.protocol, s.payment_network) || + isBridgeable(s.payment_network), + ) + .slice(0, requestedLimit) + .map((s) => ({ name: s.name, description: s.description, - url: - isLightningNative(s.protocol, s.payment_network) || - !isBridgeable(s.payment_network) - ? s.url - : bridgeUrl(s.url), + // Everything here is payable: native lightning keeps its own URL, a + // bridge-funded rail is wrapped so fetch pays it over lightning. + url: isLightningNative(s.protocol, s.payment_network) + ? s.url + : bridgeUrl(s.url), protocol: s.protocol, payment_network: s.payment_network, price_sats: s.price_sats, @@ -131,7 +140,10 @@ export async function discover(params: DiscoverParams) { reliability_score: s.reliability_score, latency_p50_ms: s.latency_p50_ms, http_method: s.http_method, - })), - total: data.total, + })); + + return { + services: payable, + total: payable.length, }; } From 374931eae246d99bc70d240b1144ad74b22d7533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:22:03 +0200 Subject: [PATCH 8/9] fix: route MPP services through l402.space/mpp-lightning endpoint Per review feedback (rolznz): MPP payment challenges are incompatible with L402 and can't be folded into one, so MPP upstreams need the gateway's dedicated inbound endpoint. The path prefix selects the rail we pay the gateway over, and we always pay via lightning - so MPP goes through /mpp-lightning/ (hands back a lightning invoice), while x402 stays on the default path (gateway folds a lightning L402 challenge). /mpp-tempo/ would demand a Tempo stablecoin we can't pay from a lightning wallet. Verified live against a real MPP/Tempo upstream: /mpp-lightning/ returns a 402 with a lnbc invoice; discover now emits mpp-lightning-prefixed URLs for MPP services and default-prefixed URLs for x402. --- src/test/discover-bridge.test.ts | 19 ++++++++++++++++--- src/tools/lightning/discover.ts | 23 ++++++++++++++--------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/test/discover-bridge.test.ts b/src/test/discover-bridge.test.ts index 460f393..89f7bdc 100644 --- a/src/test/discover-bridge.test.ts +++ b/src/test/discover-bridge.test.ts @@ -44,13 +44,16 @@ function stubIndexResponse( const bridged = (url: string) => "https://l402.space/" + encodeURIComponent(url); +// MPP upstreams route through the dedicated mpp-lightning inbound endpoint. +const bridgedMpp = (url: string) => + "https://l402.space/mpp-lightning/" + encodeURIComponent(url); afterEach(() => { vi.unstubAllGlobals(); }); describe("discover l402.space bridge wrapping", () => { - test("wraps services on a bridge-funded rail (base/solana/tempo, incl. eip155:8453 alias)", async () => { + test("wraps x402 services on a bridge-funded rail via the default endpoint (base/solana, incl. eip155:8453 alias)", async () => { stubIndexResponse([ { url: "https://a.example/api", protocol: "x402", payment_network: "Base" }, { @@ -68,7 +71,6 @@ describe("discover l402.space bridge wrapping", () => { protocol: "x402", payment_network: "Base, Solana", }, - { url: "https://e.example/api", protocol: "MPP", payment_network: "Tempo" }, ]); const result = await discover({}); @@ -78,10 +80,21 @@ describe("discover l402.space bridge wrapping", () => { bridged("https://b.example/api"), bridged("https://c.example/api"), bridged("https://d.example/api"), - bridged("https://e.example/api"), ]); }); + test("routes MPP services through the dedicated mpp-lightning endpoint, not the default one", async () => { + // MPP challenges can't be folded into an L402 one, so an MPP upstream must + // use l402.space/mpp-lightning/ to still hand our wallet a lightning invoice. + stubIndexResponse([ + { url: "https://mpp.example/api", protocol: "MPP", payment_network: "Tempo" }, + ]); + + const result = await discover({}); + + expect(result.services[0].url).toBe(bridgedMpp("https://mpp.example/api")); + }); + test("drops services on rails the bridge can't settle (stellar/polygon/stripe/testnet/none)", async () => { // l402.space only funds base/solana/tempo/lightning, so these aren't // payable from a lightning wallet at all. discover must never surface a diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 56db03c..5722075 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -6,16 +6,21 @@ export interface DiscoverParams { limit?: number; } -// The l402.space bridge re-wraps a 402-gated upstream as an L402 (lightning) -// challenge and settles the upstream on our behalf, so a lightning wallet can -// pay endpoints it couldn't settle natively (e.g. USDC-only x402 on Base). We -// wrap bridgeable discover results in the bridge URL up front so the URL a -// caller fetches is explicit - `fetch` pays it as a normal L402 endpoint, with -// no hidden per-request redirection. +// The l402.space bridge settles a 402-gated upstream on our behalf and charges +// us over lightning, so a lightning wallet can pay endpoints it couldn't settle +// natively (e.g. USDC-only x402 on Base). We wrap bridgeable discover results in +// the bridge URL up front so the URL a caller fetches is explicit - `fetch` pays +// it over lightning, with no hidden per-request redirection. const L402_SPACE_BRIDGE = "https://l402.space/"; -function bridgeUrl(url: string): string { - return `${L402_SPACE_BRIDGE}${encodeURIComponent(url)}`; +// The path prefix selects the *inbound* rail we pay the gateway over (see +// l402.space/api/info). We always pay over lightning: x402 upstreams settle fine +// via the default path (the gateway folds a lightning L402 challenge), but MPP +// challenges can't be folded into an L402 one, so MPP upstreams need the +// dedicated mpp-lightning endpoint to still hand us a lightning invoice. +function bridgeUrl(url: string, protocol: string): string { + const inboundPath = protocol === "MPP" ? "mpp-lightning/" : ""; + return `${L402_SPACE_BRIDGE}${inboundPath}${encodeURIComponent(url)}`; } // The bridge can only settle upstreams on the rails it has funded wallets for - @@ -129,7 +134,7 @@ export async function discover(params: DiscoverParams) { // bridge-funded rail is wrapped so fetch pays it over lightning. url: isLightningNative(s.protocol, s.payment_network) ? s.url - : bridgeUrl(s.url), + : bridgeUrl(s.url, s.protocol), protocol: s.protocol, payment_network: s.payment_network, price_sats: s.price_sats, From a9a41ed8bcbeb886f3403813492d851e9a768058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= <100827540+reneaaron@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:35:27 +0200 Subject: [PATCH 9/9] fix: discover total reports index match count, not returned page size Consumer testing flagged that total == services.length made agents read 'total: 10' as 'only 10 services exist'. Report the index's match count for the query instead, so the caller knows the corpus is larger than the returned (payability-filtered, sliced) page. --- src/test/discover-bridge.test.ts | 11 +++++++---- src/tools/lightning/discover.ts | 5 ++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/test/discover-bridge.test.ts b/src/test/discover-bridge.test.ts index 89f7bdc..e4e53e4 100644 --- a/src/test/discover-bridge.test.ts +++ b/src/test/discover-bridge.test.ts @@ -31,7 +31,9 @@ function stubIndexResponse( latency_p50_ms: null, http_method: "GET", })), - total: services.length, + // Sentinel distinct from services.length: total is the index's match + // count, so it must survive our payability filtering/slicing unchanged. + total: 9999, limit: 10, offset: 0, }), @@ -122,10 +124,11 @@ describe("discover l402.space bridge wrapping", () => { const result = await discover({}); expect(result.services).toEqual([]); - expect(result.total).toBe(0); + // total still reports the index's match count, not the filtered-out zero. + expect(result.total).toBe(9999); }); - test("returns only the payable services from a mixed page and reports their count", async () => { + test("returns only the payable services from a mixed page, keeping the index total", async () => { stubIndexResponse([ { url: "https://ln.example/api", @@ -147,7 +150,7 @@ describe("discover l402.space bridge wrapping", () => { "https://ln.example/api", bridged("https://base.example/api"), ]); - expect(result.total).toBe(2); + expect(result.total).toBe(9999); }); test("leaves natively lightning-payable services unwrapped", async () => { diff --git a/src/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 5722075..ccb37b9 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -149,6 +149,9 @@ export async function discover(params: DiscoverParams) { return { services: payable, - total: payable.length, + // total is the index's match count for this query, so the caller knows the + // corpus is larger than the returned page - not the number of services we + // filtered/sliced into `services`. + total: data.total, }; }