Skip to content
3 changes: 2 additions & 1 deletion src/commands/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text>", "Search query")
.option(
Expand Down
198 changes: 198 additions & 0 deletions src/test/discover-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
94 changes: 86 additions & 8 deletions src/tools/lightning/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { "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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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,
};
}
Loading