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/discover-bridge.test.ts b/src/test/discover-bridge.test.ts new file mode 100644 index 0000000..e4e53e4 --- /dev/null +++ b/src/test/discover-bridge.test.ts @@ -0,0 +1,198 @@ +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 | null; + }>, +) { + 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", + })), + // 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, + }), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +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 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" }, + { + url: "https://b.example/api", + protocol: "x402", + payment_network: "eip155:8453", // CAIP-2 for Base mainnet + }, + { + url: "https://c.example/api", + protocol: "x402", + payment_network: "Solana", + }, + { + url: "https://d.example/api", + protocol: "x402", + payment_network: "Base, Solana", + }, + ]); + + 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"), + bridged("https://d.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 + // service the wallet can't pay. 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).toEqual([]); + // 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, keeping the index total", 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://ln.example/api", + bridged("https://base.example/api"), + ]); + expect(result.total).toBe(9999); + }); + + 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://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://l402null.example/api", + "https://x402ln.example/api", + "https://multi.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/tools/lightning/discover.ts b/src/tools/lightning/discover.ts index 024e7a5..ccb37b9 100644 --- a/src/tools/lightning/discover.ts +++ b/src/tools/lightning/discover.ts @@ -6,6 +6,62 @@ export interface DiscoverParams { limit?: number; } +// 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/"; + +// 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 - +// 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 { + 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) { const url = new URL("https://402index.io/api/v1/services"); const requestedLimit = params.limit ?? 10; @@ -15,9 +71,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); - // Filter to BTC (lightning) services server-side - url.searchParams.set("payment_asset", "BTC"); - 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); @@ -46,7 +107,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; @@ -59,12 +120,23 @@ 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: 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, s.protocol), protocol: s.protocol, + payment_network: s.payment_network, price_sats: s.price_sats, price_usd: s.price_usd, category: s.category, @@ -73,7 +145,13 @@ export async function discover(params: DiscoverParams) { reliability_score: s.reliability_score, latency_p50_ms: s.latency_p50_ms, http_method: s.http_method, - })), + })); + + return { + services: payable, + // 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, }; }