Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
204 changes: 204 additions & 0 deletions src/test/discover-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
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);

afterEach(() => {
vi.unstubAllGlobals();
});

describe("discover l402.space bridge wrapping", () => {
test("wraps services on a bridge-funded rail via the default endpoint (x402 and MPP alike)", async () => {
// The gateway's default route folds a lightning L402 challenge for any
// upstream protocol, so x402 and non-lightning MPP share the same plain
// bridge path.
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",
},
{ url: "https://e.example/api", protocol: "MPP", payment_network: "Tempo" },
]);

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"),
bridged("https://e.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",
},
// ...or when the index reports the rail as the raw Bitcoin CAIP-2 id
// (like x402.albylabs.com's own challenge does) instead of "Lightning".
{
url: "https://x402btc.example/api",
protocol: "x402",
payment_network: "bip122:000000000019d6689c085ae165831e93",
},
// MPP is rail-agnostic too: an MPP service on the Lightning rail is paid
// directly, never bridged.
{
url: "https://mppln.example/api",
protocol: "MPP",
payment_network: "Lightning",
},
]);

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",
"https://x402btc.example/api",
"https://mppln.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");
});
});
101 changes: 93 additions & 8 deletions src/tools/lightning/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,67 @@ 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 default gateway route folds a lightning L402 challenge regardless of the
// upstream's protocol (x402 or MPP - verified: an MPP/Tempo upstream wrapped
// this way returns an L402 challenge with rail "mpp-tempo"), so every bridged
// service uses the same plain path. The gateway's /mpp-* endpoints select a
// different *inbound* rail and aren't needed when paying over lightning.
function bridgeUrl(url: string): string {
return `${L402_SPACE_BRIDGE}${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 rails we handle (eip155:8453 is Base mainnet; the bip122 id is
// Bitcoin mainnet, which lightning-native x402 challenges use - e.g.
// x402.albylabs.com - and must stay unwrapped, not bridged). Testnets like
// Base Sepolia (eip155:84532 / "Base Sepolia") intentionally don't match.
const NETWORK_ALIASES: Record<string, string> = {
"eip155:8453": "base",
"bip122:000000000019d6689c085ae165831e93": "lightning",
};

// payment_network can list several rails, e.g. "Base, Solana".
function paymentRails(paymentNetwork: string | null): string[] {
if (!paymentNetwork) return [];
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 +76,16 @@ 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. A floor of 50 keeps small
// requests from starving the filter (a page of 6 can easily be all
// unpayable rails); the index caps a page at 200.
const fetchLimit = Math.min(200, Math.max(50, requestedLimit * 2));
url.searchParams.set("limit", String(fetchLimit));

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
Expand Down Expand Up @@ -46,7 +114,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 +127,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),
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 +152,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