Skip to content
Closed
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
2 changes: 1 addition & 1 deletion ts/src/adapters/inbound/cli/commands/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ const listFields = z.object({
type: z.string().trim().min(1).optional(),
category: z.string().trim().min(1).optional(),
capability: z.string().trim().min(1).optional(),
network: z.string().trim().min(1).optional().describe("CAIP-2 network id"),
network: z.string().trim().min(1).optional().describe("network alias or CAIP-2 network id"),
includeBlocked: z.boolean().default(false).describe("include providers marked as blocked"),
});

Expand Down
1 change: 1 addition & 0 deletions ts/src/adapters/outbound/x402/legacy-facilitator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ it.each(
const service = new X402Service(
new X402PaymentClient(signers as never),
{} as never,
{} as never,
new X402HttpServer(facilitator, 2000, () => {}),
);
const pending = service.roundtrip(
Expand Down
55 changes: 53 additions & 2 deletions ts/src/adapters/outbound/x402/provider-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class X402ProviderCatalog extends Catalog {
}
}
describe("X402ProviderCatalog", () => {
it("accepts the Base alias when filtering the online catalog's canonical chain ids", async () => {
it("accepts the Base canonical ID when filtering the online catalog's canonical chain ids", async () => {
const catalog = new X402ProviderCatalog(
vi.fn(
async () =>
Expand All @@ -24,7 +24,9 @@ describe("X402ProviderCatalog", () => {
),
),
);
await expect(catalog.list({ limit: 20, offset: 0, network: "base" })).resolves.toMatchObject({
await expect(
catalog.list({ limit: 20, offset: 0, network: "eip155:8453" }),
).resolves.toMatchObject({
count: 1,
filters: { network: "eip155:8453" },
});
Expand Down Expand Up @@ -173,3 +175,52 @@ it("omits search internals and endpoint bodies from lists while preserving exten
extraMetadata: { billingMode: "usage" },
});
});

// Exercise the production alias registry together with catalog filtering.
describe("provider network resolution", () => {
it("accepts every global alias, including configured aliases, without changing unfiltered lists", async () => {
const { ConfigLoader, NetworkRegistry } = await import("../config/index.js");
const { X402Service } = await import("../../../application/use-cases/x402-service.js");
const { mkdtempSync, rmSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const root = mkdtempSync(join(tmpdir(), "provider-alias-"));
try {
const config = ConfigLoader.load({ WALLET_CLI_HOME: root });
config.aliases["my-testnet"] = "eip155:11155111";
const registry = new NetworkRegistry(config);
const providers = registry
.all()
.map(({ id }) => ({ fqn: `demo/${id.replace(":", "-")}`, chains: [id] }));
const catalog = new X402ProviderCatalog(async () => Response.json({ version: 1, providers }));
const service = new X402Service({} as never, catalog, registry);
for (const alias of Object.keys(config.aliases)) {
const id = registry.resolve(alias).id;
for (const input of [alias, alias.toUpperCase()]) {
await expect(
service.providerList({ limit: 100, offset: 0, network: input }),
).resolves.toMatchObject({
count: 1,
filters: { network: id },
results: [{ chains: [id] }],
});
}
}
await expect(service.providerList({ limit: 100, offset: 0 })).resolves.toMatchObject({
count: providers.length,
filters: {},
});
await expect(
service.providerList({ limit: 100, offset: 0, network: "eip155:999999" }),
).resolves.toMatchObject({ count: 0, results: [] });
await expect(
service.providerList({ limit: 100, offset: 0, network: "tron:0xcd8690dc" }),
).resolves.toMatchObject({ count: 1, filters: { network: "tron:3448148188" } });
expect(() => service.providerList({ limit: 100, offset: 0, network: "typo" })).toThrow(
"unknown network: typo",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
20 changes: 6 additions & 14 deletions ts/src/adapters/outbound/x402/provider-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ export class X402ProviderCatalog implements ProviderCatalogPort {
validateFilter(providers, "type", input.type, "type is not exposed by the catalog yet");
validateFilter(providers, "category", input.category);
validateArrayFilter(providers, "featuredTags", input.capability);
const wantedNetwork = input.network ? normalizeNetworkAlias(input.network) : undefined;
validateArrayFilter(providers, "chains", wantedNetwork);
const wantedNetwork = input.network ? normalizeNetwork(input.network) : undefined;
if (wantedNetwork && !/^(?:tron|eip155):[0-9]+$/.test(wantedNetwork))
throw new UsageError(
"invalid_value",
"provider network filter requires a canonical CAIP-2 network ID",
);
providers = providers.filter(
(provider) =>
matches(provider, "type", input.type) &&
Expand Down Expand Up @@ -246,18 +250,6 @@ function validateArrayFilter(
}
}

function normalizeNetworkAlias(value: string): string {
const aliases: Record<string, string> = {
tron: "tron:728126428",
nile: "tron:3448148188",
shasta: "tron:2494104990",
bsc: "eip155:56",
"bsc-testnet": "eip155:97",
base: "eip155:8453",
};
return aliases[value.toLowerCase()] ?? normalizeNetwork(value);
}

function validateCatalog(value: Record<string, unknown>): Record<string, unknown> {
if (value.version !== 1)
throw new TransportError("catalog_schema_unsupported", "x402 catalog version is unsupported");
Expand Down
1 change: 1 addition & 0 deletions ts/src/adapters/outbound/x402/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ it.each([
const service = new X402Service(
new X402PaymentClient(resolver),
{} as ProviderCatalogPort,
{} as never,
new X402HttpServer(facilitator as typeof fetch),
);
const result = await service.roundtrip(
Expand Down
2 changes: 2 additions & 0 deletions ts/src/application/use-cases/bai-recharge-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ it("previews a recipient recharge without creating an order or obtaining any sig
const payments = new X402Service(
new X402PaymentClient(signing as never),
{} as never,
{} as never,
new X402HttpServer(undefined, 1000, () => {}),
);
const api = {
Expand Down Expand Up @@ -271,6 +272,7 @@ it("checks relay configuration before creating any preorder", async () => {
const payments = new X402Service(
new X402PaymentClient({} as never),
{} as never,
{} as never,
new X402HttpServer(),
);
const service = new BaiService(
Expand Down
15 changes: 10 additions & 5 deletions ts/src/application/use-cases/x402-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe("X402Service", () => {
endpoints: vi.fn(async () => ({ endpoints: [] })),
update: vi.fn(async () => ({ updated: true })),
} as unknown as ProviderCatalogPort;
const service = new X402Service(payment, catalog);
const service = new X402Service(payment, catalog, { resolve: vi.fn() });
const scope = {} as never;
const network = { id: "eip155:56" } as never;

Expand All @@ -35,10 +35,15 @@ it.each([false, true])(
if (failure) throw new Error("settlement failed");
return { settled: true };
});
const service = new X402Service({ pay }, {} as ProviderCatalogPort, {
validate: vi.fn(),
start: async () => ({ details: { payUrl: "http://127.0.0.1:45678/pay" }, close }),
});
const service = new X402Service(
{ pay },
{} as ProviderCatalogPort,
{ resolve: vi.fn() },
{
validate: vi.fn(),
start: async () => ({ details: { payUrl: "http://127.0.0.1:45678/pay" }, close }),
},
);
const result = service.roundtrip({} as never, {} as never, {
payTo: "trusted",
amount: "10",
Expand Down
15 changes: 14 additions & 1 deletion ts/src/application/use-cases/x402-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import type { ProviderCatalogPort, ProviderListInput } from "../ports/provider-c
import type { X402PayInput, X402PaymentPort } from "../ports/x402-payment.js";
import type { X402ServeInput, X402ServerPort } from "../ports/x402-server.js";

import type { NetworkRegistry } from "../ports/network-registry.js";

export class X402Service {
constructor(
private readonly payments: X402PaymentPort,
private readonly catalog: ProviderCatalogPort,
private readonly networks: Pick<NetworkRegistry, "resolve">,
private readonly server?: X402ServerPort,
) {}

Expand All @@ -16,7 +19,17 @@ export class X402Service {
}

providerList(input: ProviderListInput) {
return this.catalog.list(input);
const network = input.network;
// Catalog filters need no RPC configuration for an explicit chain ID.
// Resolve names through the same registry used by global --network.
const canonical =
network && !/^(?:tron|eip155):(?:[0-9]+|0x[0-9a-f]+)$/i.test(network)
? this.networks.resolve(network).id
: network;
return this.catalog.list({
...input,
...(canonical === undefined ? {} : { network: canonical }),
});
}

providerShow(fqn: string) {
Expand Down
1 change: 1 addition & 0 deletions ts/src/bootstrap/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export function composeCliRuntime(options: BootstrapOptions) {
const x402Service = new X402Service(
x402Payments,
new X402ProviderCatalog(undefined, undefined, timeoutMs),
networkRegistry,
new ManagedX402Server(new X402HttpServer(undefined, timeoutMs)),
);
registerBaiCommands(
Expand Down