diff --git a/README.md b/README.md index 69cdec1..30354ce 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,32 @@ const { result } = await response.json(); console.log('Block:', BigInt(result)); ``` +### Discover a Paid Origin + +```typescript +import { createQuicknodeX402Client, discoverX402Origin } from '@quicknode/x402'; + +const discovery = await discoverX402Origin('https://x402.quicknode.com'); +const resource = discovery.resources.find((entry) => entry.method === 'POST'); + +if (!resource) { + throw new Error('No paid resource found'); +} + +const client = await createQuicknodeX402Client({ + baseUrl: 'https://x402.quicknode.com', + network: discovery.accepts[0]?.network ?? 'eip155:84532', + evmPrivateKey: '0xYOUR_PRIVATE_KEY', + paymentModel: 'pay-per-request', +}); + +const response = await client.fetch(resource.url, { + method: resource.method ?? 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] }), +}); +``` + ### Nanopayment (Sub-Cent via Circle Gateway) ```typescript @@ -107,6 +133,7 @@ const response = await client.fetch('https://x402.quicknode.com/solana-devnet', ## Features - **Three Payment Models** — Per-request ($0.001, no auth), credit drawdown (SIWX auth + bulk credits), or nanopayment ($0.0001 via Circle Gateway) +- **Origin Discovery** — Reads `.well-known/x402` or OpenAPI payment metadata to list paid resources before fetching - **SIWX Authentication** — Automatic Sign-In with X (EVM EIP-191 + Solana Ed25519) on 402 responses (credit drawdown) - **x402 v2 Payments** — Automatic stablecoin micropayments when credits are exhausted - **No Billing for Errors** — Per-request only settles payment after a successful upstream response @@ -155,6 +182,14 @@ Async factory that creates a fully-configured client. Async due to SVM key deriv | `httpClient` | `x402HTTPClient` | Underlying HTTP client (for advanced hook registration) | | `gatewayClient` | `GatewayClient \| undefined` | Circle Gateway client for deposit/balance management (nanopayment mode only) | +### `discoverX402Origin(origin, options?): Promise` + +Reads an x402 seller origin and returns paid resource metadata. The helper tries +`/.well-known/x402` first, then falls back to `/openapi.json` routes that expose +`x-payment-info`, `x-agent-discovery`, or `402` response metadata. Discovery does +not spend funds; pass a returned `resource.url` to `client.fetch()` to run the +normal x402 payment flow. + ### gRPC-Web Transport ```typescript @@ -210,6 +245,8 @@ import { CAIP2_TO_GATEWAY_CHAIN, isBatchPayment, supportsBatching, + // Origin discovery + discoverX402Origin, } from '@quicknode/x402'; ``` @@ -274,6 +311,9 @@ import type { GatewayBalances, GatewayClientConfig, GatewayDepositResult, + X402DiscoveredResource, + X402DiscoveryResult, + X402PaymentAccept, } from '@quicknode/x402'; ``` diff --git a/src/discovery.ts b/src/discovery.ts new file mode 100644 index 0000000..5253158 --- /dev/null +++ b/src/discovery.ts @@ -0,0 +1,325 @@ +import type { + X402DiscoveredResource, + X402DiscoveryOptions, + X402DiscoveryResult, + X402PaymentAccept, +} from './types.js'; + +const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function readVersion(value: unknown): number | string | undefined { + return readNumber(value) ?? readString(value); +} + +function normalizeOrigin(origin: string | URL): URL { + const url = new URL(origin); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`x402 discovery requires an http(s) origin. Got: ${url.protocol}`); + } + return new URL(url.origin); +} + +function resolveUrl(value: unknown, base: URL): string | undefined { + const url = readString(value); + if (!url) return undefined; + + try { + return new URL(url, base).href; + } catch { + return undefined; + } +} + +function hasScheme(value: string): boolean { + return /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value); +} + +function appendOpenApiPath(path: string, serverBase: URL): string | undefined { + try { + const resource = new URL(path, 'http://openapi.local'); + const basePath = serverBase.pathname === '/' ? '' : serverBase.pathname.replace(/\/$/, ''); + const url = new URL(serverBase.href); + url.pathname = `${basePath}${resource.pathname}`; + url.search = resource.search; + url.hash = resource.hash; + return url.href; + } catch { + return undefined; + } +} + +function resolveOpenApiResourceUrl( + value: unknown, + serverBase: URL, + fallbackPath: string, +): string | undefined { + const explicitUrl = readString(value); + if (explicitUrl && hasScheme(explicitUrl)) { + return resolveUrl(explicitUrl, serverBase); + } + + return appendOpenApiPath(explicitUrl ?? fallbackPath, serverBase); +} + +function pathFromUrl(url: string, origin: URL): string | undefined { + try { + const parsed = new URL(url); + return parsed.origin === origin.origin ? `${parsed.pathname}${parsed.search}` : undefined; + } catch { + return undefined; + } +} + +function parseAccepts(value: unknown): X402PaymentAccept[] { + return asArray(value).filter(isRecord) as X402PaymentAccept[]; +} + +function buildResource( + value: unknown, + origin: URL, + defaultAccepts: X402PaymentAccept[], +): X402DiscoveredResource | null { + if (typeof value === 'string') { + const url = resolveUrl(value, origin); + if (!url) return null; + return { + url, + path: pathFromUrl(url, origin), + accepts: defaultAccepts, + }; + } + + if (!isRecord(value)) return null; + + const url = resolveUrl(value.url, origin) ?? resolveUrl(value.path, origin); + if (!url) return null; + + const accepts = parseAccepts(value.accepts); + + return { + url, + path: readString(value.path) ?? pathFromUrl(url, origin), + method: readString(value.method)?.toUpperCase(), + title: readString(value.title), + summary: readString(value.summary), + description: readString(value.description), + category: readString(value.category), + providerName: readString(value.providerName), + providerUrl: readString(value.providerUrl), + price: readString(value.price), + priceUsd: readString(value.priceUsd), + accepts: accepts.length > 0 ? accepts : defaultAccepts, + metadata: value.metadata && isRecord(value.metadata) ? value.metadata : undefined, + input: value.input, + output: value.output, + }; +} + +async function fetchJson( + fetchImpl: typeof globalThis.fetch, + url: string, +): Promise<{ ok: true; data: unknown } | { ok: false; status?: number; error: string }> { + try { + const response = await fetchImpl(url, { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + return { ok: false, status: response.status, error: `HTTP ${response.status}` }; + } + + return { ok: true, data: await response.json() }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } +} + +function parseWellKnownX402( + data: unknown, + origin: URL, + wellKnownUrl: string, +): X402DiscoveryResult | null { + if (!isRecord(data)) return null; + + const seller = isRecord(data.seller) ? data.seller : {}; + const facilitator = isRecord(data.facilitator) ? data.facilitator : {}; + const accepts = parseAccepts(data.accepts); + + const detailedResources = asArray(data.resourcesDetailed) + .map((resource) => buildResource(resource, origin, accepts)) + .filter((resource): resource is X402DiscoveredResource => resource !== null); + const resourceUrls = asArray(data.resources) + .map((resource) => buildResource(resource, origin, accepts)) + .filter((resource): resource is X402DiscoveredResource => resource !== null); + const resources = detailedResources.length > 0 ? detailedResources : resourceUrls; + + return { + origin: readString(seller.origin) ?? origin.origin, + source: 'well-known', + enabled: typeof data.enabled === 'boolean' ? data.enabled : undefined, + version: readVersion(data.version), + x402Version: readNumber(data.x402Version), + facilitatorUrl: readString(facilitator.url), + openapiUrl: resolveUrl(seller.openapi, origin), + wellKnownUrl: resolveUrl(seller.wellKnown, origin) ?? wellKnownUrl, + catalogUrl: resolveUrl(seller.catalog, origin), + payTo: readString(seller.payTo), + accepts, + resources, + }; +} + +function getOpenApiServerBase(data: Record, origin: URL): URL { + const servers = asArray(data.servers); + for (const server of servers) { + if (!isRecord(server)) continue; + const url = resolveUrl(server.url, origin); + if (url) return new URL(url); + } + return origin; +} + +function priceFromPaymentInfo( + paymentInfo: Record | undefined, +): string | undefined { + const price = paymentInfo?.price; + if (!isRecord(price)) return undefined; + + const amount = readString(price.amount); + const currency = readString(price.currency); + if (amount && currency) return `${amount} ${currency}`; + return amount; +} + +function parseOpenApi(data: unknown, origin: URL, openapiUrl: string): X402DiscoveryResult | null { + if (!isRecord(data)) return null; + + const serverBase = getOpenApiServerBase(data, origin); + const info = isRecord(data.info) ? data.info : {}; + const xDiscovery = isRecord(data['x-discovery']) ? data['x-discovery'] : {}; + const paths = isRecord(data.paths) ? data.paths : {}; + const resources: X402DiscoveredResource[] = []; + + for (const [path, pathItem] of Object.entries(paths)) { + if (!isRecord(pathItem)) continue; + + for (const [method, operation] of Object.entries(pathItem)) { + if (!HTTP_METHODS.has(method) || !isRecord(operation)) continue; + + const paymentInfo = isRecord(operation['x-payment-info']) + ? operation['x-payment-info'] + : undefined; + const agentDiscovery = isRecord(operation['x-agent-discovery']) + ? operation['x-agent-discovery'] + : undefined; + const responses = isRecord(operation.responses) ? operation.responses : {}; + const hasPaymentResponse = paymentInfo || agentDiscovery || Boolean(responses['402']); + + if (!hasPaymentResponse) continue; + + const url = resolveOpenApiResourceUrl(agentDiscovery?.url, serverBase, path); + if (!url) continue; + + const response200 = isRecord(responses['200']) ? responses['200'] : {}; + const content = isRecord(response200.content) ? response200.content : {}; + const jsonContent = isRecord(content['application/json']) ? content['application/json'] : {}; + + resources.push({ + url, + path, + method: method.toUpperCase(), + title: readString(agentDiscovery?.title), + summary: readString(operation.summary), + description: readString(agentDiscovery?.description) ?? readString(operation.description), + category: readString(agentDiscovery?.category), + providerName: readString(agentDiscovery?.providerName), + providerUrl: readString(agentDiscovery?.providerUrl), + price: priceFromPaymentInfo(paymentInfo), + priceUsd: readString(agentDiscovery?.priceUsd), + accepts: [], + metadata: { + operationId: readString(operation.operationId), + protocols: asArray(paymentInfo?.protocols), + }, + input: operation.parameters, + output: jsonContent.schema, + }); + } + } + + return { + origin: origin.origin, + source: 'openapi', + openapiUrl, + wellKnownUrl: resolveUrl(xDiscovery.wellKnownX402, origin), + catalogUrl: resolveUrl(xDiscovery.catalog, origin), + accepts: [], + resources, + info: { + title: readString(info.title), + description: readString(info.description), + version: readString(info.version), + guidance: readString(info['x-guidance']), + }, + ownershipProofs: asArray(xDiscovery.ownershipProofs).filter( + (proof): proof is string => typeof proof === 'string', + ), + }; +} + +/** + * Discover paid x402 resources exposed by an origin. + * + * The helper prefers the conventional `/.well-known/x402` manifest and falls + * back to OpenAPI metadata at `/openapi.json`. It performs metadata discovery + * only; use `client.fetch(resource.url)` to trigger the normal 402 payment flow. + */ +export async function discoverX402Origin( + originInput: string | URL, + options: X402DiscoveryOptions = {}, +): Promise { + const origin = normalizeOrigin(originInput); + const fetchImpl = options.fetch ?? globalThis.fetch; + if (!fetchImpl) { + throw new Error('discoverX402Origin requires a fetch implementation.'); + } + + const errors: string[] = []; + const wellKnownUrl = new URL('/.well-known/x402', origin).href; + const wellKnown = await fetchJson(fetchImpl, wellKnownUrl); + if (wellKnown.ok) { + const parsed = parseWellKnownX402(wellKnown.data, origin, wellKnownUrl); + if (parsed && parsed.resources.length > 0) return parsed; + errors.push(`${wellKnownUrl}: no x402 resources found`); + } else { + errors.push(`${wellKnownUrl}: ${wellKnown.error}`); + } + + const openapiUrl = new URL('/openapi.json', origin).href; + const openapi = await fetchJson(fetchImpl, openapiUrl); + if (openapi.ok) { + const parsed = parseOpenApi(openapi.data, origin, openapiUrl); + if (parsed && parsed.resources.length > 0) return parsed; + errors.push(`${openapiUrl}: no paid OpenAPI resources found`); + } else { + errors.push(`${openapiUrl}: ${openapi.error}`); + } + + throw new Error(`Unable to discover x402 resources for ${origin.origin}. ${errors.join('; ')}`); +} diff --git a/src/index.ts b/src/index.ts index 26dec59..4484ec3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,8 @@ // Auth (for manual usage) export { preAuthenticate } from './auth.js'; export { createQuicknodeX402Client } from './client.js'; +// Discovery +export { discoverX402Origin } from './discovery.js'; // Composable utilities (for standalone usage, following payment-identifier pattern) export { createSIWxClientHook, // re-exported from @x402/extensions @@ -40,4 +42,9 @@ export type { QuicknodeX402Config, SIWxSigner, SolanaSigner, + X402DiscoveredResource, + X402DiscoveryOptions, + X402DiscoveryResult, + X402DiscoverySource, + X402PaymentAccept, } from './types.js'; diff --git a/src/types.ts b/src/types.ts index bee4a5f..04ba32c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -76,6 +76,72 @@ export interface GrpcTransportOptions { interceptors?: Interceptor[]; } +export type X402DiscoverySource = 'well-known' | 'openapi'; + +export interface X402DiscoveryOptions { + /** Custom fetch implementation for testing or non-standard runtimes. */ + fetch?: typeof globalThis.fetch; +} + +export interface X402PaymentAccept { + scheme?: string; + network?: string; + amount?: string; + asset?: string; + payTo?: string; + maxTimeoutSeconds?: number; + extra?: Record; + [key: string]: unknown; +} + +export interface X402DiscoveredResource { + /** Absolute URL of the paid resource. */ + url: string; + /** Origin-relative path when available. */ + path?: string; + method?: string; + title?: string; + summary?: string; + description?: string; + category?: string; + providerName?: string; + providerUrl?: string; + /** Human-readable price, if exposed by the discovery document. */ + price?: string; + /** USD price string, if exposed by the discovery document. */ + priceUsd?: string; + /** Payment requirements advertised by the discovery document, when available. */ + accepts?: X402PaymentAccept[]; + /** Additional machine-readable metadata from the discovery source. */ + metadata?: Record; + /** Input schema or parameter metadata. */ + input?: unknown; + /** Output schema or example metadata. */ + output?: unknown; +} + +export interface X402DiscoveryResult { + origin: string; + source: X402DiscoverySource; + enabled?: boolean; + version?: number | string; + x402Version?: number; + facilitatorUrl?: string; + openapiUrl?: string; + wellKnownUrl?: string; + catalogUrl?: string; + payTo?: string; + accepts: X402PaymentAccept[]; + resources: X402DiscoveredResource[]; + info?: { + title?: string; + description?: string; + version?: string; + guidance?: string; + }; + ownershipProofs?: string[]; +} + // Re-export key types for consumer convenience export type { ClientEvmSigner } from '@x402/evm'; export type { EVMSigner, SIWxSigner, SolanaSigner } from '@x402/extensions/sign-in-with-x'; diff --git a/test/discovery.spec.ts b/test/discovery.spec.ts new file mode 100644 index 0000000..fa63352 --- /dev/null +++ b/test/discovery.spec.ts @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { discoverX402Origin } from '../src/discovery.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('discoverX402Origin', () => { + it('discovers resources from /.well-known/x402', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + version: '1.0.0', + x402Version: 2, + enabled: true, + seller: { + origin: 'https://seller.example', + wellKnown: 'https://seller.example/.well-known/x402', + openapi: 'https://seller.example/openapi.json', + catalog: 'https://seller.example/catalog.json', + payTo: '0x2eDbF699657ae1A09D9C3833FD162A6b59344364', + }, + facilitator: { url: 'https://api.cdp.coinbase.com/platform/v2/x402' }, + accepts: [ + { + scheme: 'exact', + network: 'eip155:84532', + payTo: '0x2eDbF699657ae1A09D9C3833FD162A6b59344364', + }, + ], + resourcesDetailed: [ + { + path: '/weather', + url: 'https://seller.example/weather', + method: 'GET', + priceUsd: '$0.001', + title: 'Minimal paid HTTP proof', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await discoverX402Origin('https://seller.example'); + + expect(fetchSpy).toHaveBeenCalledWith('https://seller.example/.well-known/x402', { + headers: { Accept: 'application/json' }, + }); + expect(result.source).toBe('well-known'); + expect(result.enabled).toBe(true); + expect(result.version).toBe('1.0.0'); + expect(result.x402Version).toBe(2); + expect(result.facilitatorUrl).toBe('https://api.cdp.coinbase.com/platform/v2/x402'); + expect(result.accepts[0]?.network).toBe('eip155:84532'); + expect(result.resources).toEqual([ + expect.objectContaining({ + url: 'https://seller.example/weather', + path: '/weather', + method: 'GET', + priceUsd: '$0.001', + title: 'Minimal paid HTTP proof', + }), + ]); + expect(result.resources[0]?.accepts?.[0]?.scheme).toBe('exact'); + }); + + it('falls back to OpenAPI x-payment-info metadata', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => { + if (String(url).endsWith('/.well-known/x402')) { + return new Response('not found', { status: 404 }); + } + + return new Response( + JSON.stringify({ + openapi: '3.1.0', + info: { + title: 'Paid API', + description: 'OpenAPI discovery fixture', + version: '0.1.0', + 'x-guidance': 'Call a paid route without payment to receive PAYMENT-REQUIRED.', + }, + servers: [{ url: 'https://seller.example/v1' }], + paths: { + '/merchant-payout-plan': { + get: { + summary: 'Bitcoin merchant payout plan', + description: 'Returns a payout plan.', + operationId: 'getMerchantPayoutPlan', + parameters: [{ name: 'batchSize', in: 'query' }], + responses: { + 200: { + content: { + 'application/json': { + schema: { type: 'object', required: ['plan'] }, + }, + }, + }, + 402: { description: 'Payment Required' }, + }, + 'x-payment-info': { + price: { mode: 'fixed', currency: 'USD', amount: '0.003' }, + protocols: [{ x402: {} }], + }, + 'x-agent-discovery': { + method: 'GET', + priceUsd: '$0.003', + title: 'Bitcoin merchant payout plan', + category: 'Data', + }, + }, + }, + }, + 'x-discovery': { + wellKnownX402: 'https://seller.example/.well-known/x402', + catalog: 'https://seller.example/catalog.json', + ownershipProofs: ['0x2eDbF699657ae1A09D9C3833FD162A6b59344364'], + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + + const result = await discoverX402Origin('https://seller.example'); + + expect(result.source).toBe('openapi'); + expect(result.info?.title).toBe('Paid API'); + expect(result.info?.guidance).toContain('PAYMENT-REQUIRED'); + expect(result.ownershipProofs).toEqual(['0x2eDbF699657ae1A09D9C3833FD162A6b59344364']); + expect(result.resources).toEqual([ + expect.objectContaining({ + url: 'https://seller.example/v1/merchant-payout-plan', + path: '/merchant-payout-plan', + method: 'GET', + title: 'Bitcoin merchant payout plan', + summary: 'Bitcoin merchant payout plan', + price: '0.003 USD', + priceUsd: '$0.003', + category: 'Data', + input: [{ name: 'batchSize', in: 'query' }], + output: { type: 'object', required: ['plan'] }, + }), + ]); + }); + + it('normalizes string resources from the well-known manifest', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + accepts: [{ scheme: 'exact', network: 'eip155:84532' }], + resources: ['/weather'], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await discoverX402Origin('https://seller.example/docs'); + + expect(result.origin).toBe('https://seller.example'); + expect(result.resources).toEqual([ + { + url: 'https://seller.example/weather', + path: '/weather', + accepts: [{ scheme: 'exact', network: 'eip155:84532' }], + }, + ]); + }); + + it('does not set origin-relative path for cross-origin resources', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + accepts: [{ scheme: 'exact', network: 'eip155:84532' }], + resources: ['https://other.example/api'], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const result = await discoverX402Origin('https://seller.example'); + + expect(result.resources[0]?.url).toBe('https://other.example/api'); + expect(result.resources[0]?.path).toBeUndefined(); + }); + + it('throws a clear error when neither discovery source has resources', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('not found', { status: 404 })); + + await expect(discoverX402Origin('https://seller.example')).rejects.toThrow( + 'Unable to discover x402 resources for https://seller.example', + ); + }); +});