Skip to content
Draft
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
60 changes: 60 additions & 0 deletions src/test/fetch-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 3 additions & 2 deletions src/tools/lightning/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
52 changes: 39 additions & 13 deletions src/tools/lightning/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(), {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if there are any side effects of this e.g. regarding getAlby/js-lightning-tools#328

if the client doesn't know at all they are going through this bridge, if they do the request again passing a macaroon (or x402/MPP equivalent) I wonder if this will break.

Would it be possible to wrap the discover response urls with the bridge url instead?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be more in-line also if we decide to switch to l402.space for the discovery recommendations

wallet: client,
maxAmount: maxAmountSats,
});
}

const responseContent = await result.text();
if (!result.ok) {
throw new Error(
Expand Down
Loading