diff --git a/.changeset/bazaar-seller-helpers.md b/.changeset/bazaar-seller-helpers.md new file mode 100644 index 0000000..04f47d1 --- /dev/null +++ b/.changeset/bazaar-seller-helpers.md @@ -0,0 +1,13 @@ +--- +'lens': minor +--- + +Add seller-side Bazaar discovery helpers (`src/bazaar/declare.ts`): `param.*` +constructors that make per-parameter descriptions a required positional +argument, `declareHttpResource` / `declareMcpTool` builders that assemble the +`extensions.bazaar` declaration and its JSON Schema, and +`validateDeclaration` / `assertDeclaration` which run the catalog's own +`validateListing` plus seller-side metadata-quality rules so malformed listings +fail at development time rather than in production. Lens's own gated routes +(`/price`, `/candles`, `/pools`, `/price/twap`, and the MCP price tool) are +declared with them in `src/bazaar/lensListings.ts`. diff --git a/docs/x402/bazaar-seller-guide.md b/docs/x402/bazaar-seller-guide.md new file mode 100644 index 0000000..830aa93 --- /dev/null +++ b/docs/x402/bazaar-seller-guide.md @@ -0,0 +1,115 @@ +# Declaring a discoverable, priced endpoint + +Seller-side helpers for the x402 Bazaar discovery extension. The goal from §3.6 +of the RFP: **from docs to a paid, discoverable endpoint in well under an hour.** + +## Why per-parameter descriptions are the whole point + +The buyer is software. An agent choosing between two price feeds cannot open +your docs — it has only what your listing declares. `GET /price/:assetA/:assetB` +tells it almost nothing. The same endpoint with each parameter's accepted form +spelled out, the response described, and the price stated is *selectable*. + +Ranking (#129) can only rank on what is declared, so metadata quality is the +ceiling on search quality no matter how good the retrieval is. That is why +`description` is the **first positional argument** of every parameter +constructor, and why a declaration missing one does not validate. It is not a +warning you can ship past. + +## The short version + +```ts +import { declareHttpResource, assertDeclaration, param } from './bazaar/declare' + +const pathParams = { + assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".', { example: 'XLM' }), + assetB: param.string('Quote asset, same form as assetA. Price is assetA in assetB.'), +} + +export const priceListing = assertDeclaration( + declareHttpResource({ + url: 'https://lens.example/price/:assetA/:assetB', + method: 'GET', + network: 'testnet', + routeTemplate: '/price/:assetA/:assetB', + description: 'Unified Stellar price for an asset pair, across SDEX and AMM pools.', + accepts: [{ scheme: 'exact', amount: '100000', asset: 'USDC', payTo: PAY_TO, maxTimeoutSeconds: 60 }], + pathParams, + output: { type: 'object', properties: { price: { type: 'string', description: 'Mid price, decimal string.' } } }, + }), + { pathParams, routeTemplate: '/price/:assetA/:assetB' }, +) +``` + +That is the whole declaration. It produces a complete +`RegisterBazaarResourceInput` — resource block, `accepts`, and the +`extensions.bazaar` declaration with a proper JSON Schema — and throws at module +load if anything is wrong. + +## Parameters + +| Constructor | Emits | +| --- | --- | +| `param.string(description, opts?)` | `{ type: 'string', description }` | +| `param.number(description, opts?)` | `{ type: 'number', description }` | +| `param.integer(description, opts?)` | `{ type: 'integer', description }` | +| `param.boolean(description, opts?)` | `{ type: 'boolean', description }` | +| `param.enumOf(values, description, opts?)` | `{ type: 'string', enum: values, description }` | + +`opts` takes `required` (default `true`), `example` and `default`. An `example` +is worth more to an agent than another sentence of prose — give it a value it +can copy. + +Parameters go into `pathParams`, `queryParams` (GET/HEAD/DELETE) or `body` +(POST/PUT/PATCH); the helper picks the right slot from the method and sets +`bodyType` for you. Each set becomes a JSON Schema object with +`additionalProperties: false` and a `required` list built from the flags. + +## MCP tools + +Same idea, one call: + +```ts +declareMcpTool({ + url: 'https://lens.example/mcp', + network: 'testnet', + toolName: 'get_price', + transport: 'streamable-http', + description: 'Unified Stellar price for an asset pair.', + accepts, + input: { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') }, +}) +``` + +## Validation happens before anything is sent + +`validateDeclaration(listing, spec)` returns +`{ ok: true, listing }` or `{ ok: false, problems }`. Each problem carries the +same `{ field, code, message }` shape the catalog uses for soft drops, so what +you see locally is what the catalog would have told you — except you see it at +development time rather than in an `EXTENSION-RESPONSES` header in production. + +It runs the catalog's **own** `validateListing` under the hood, so the two +cannot drift apart, then adds the rules the catalog cannot enforce because by +then the metadata is all it has: + +| Code | Meaning | +| --- | --- | +| `missing_param_description` | A parameter has no description | +| `param_description_too_short` | Under 12 characters — name the accepted values, not the field | +| `weak_resource_description` | The resource description is missing or too short to rank on | +| `undeclared_route_param` | `routeTemplate` names a `:param` absent from `pathParams` | +| `missing_accepts` | No payment requirement — not discoverable as a paid endpoint | + +`assertDeclaration()` is the same check that throws, listing every problem with +its field path. Call it at module load on your own listings: a malformed listing +then fails the boot, instead of quietly never appearing in the Bazaar. + +## Lens's own endpoints + +`src/bazaar/lensListings.ts` declares the routes `src/middleware/x402.ts` gates +— `/price/:assetA/:assetB`, `/candles/:assetA/:assetB`, `/pools` and +`/price/twap/:assetA/:assetB` — using exactly these helpers, with the MCP face +of the price feed alongside. They go through `assertDeclaration` at module load. +If declaring metadata for our own price feed were awkward, it would be awkward +for everyone, and this is where we would find that out. diff --git a/src/__tests__/bazaarDeclare.test.ts b/src/__tests__/bazaarDeclare.test.ts new file mode 100644 index 0000000..f0f6cc4 --- /dev/null +++ b/src/__tests__/bazaarDeclare.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from 'vitest' +import { + assertDeclaration, + declareHttpResource, + declareMcpTool, + param, + validateDeclaration, + type DeclaredParam, +} from '../bazaar/declare' +import { lensListings, lensMcpListing } from '../bazaar/lensListings' + +const PAY_TO = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' +const accepts = [ + { scheme: 'exact', amount: '100000', asset: 'USDC', payTo: PAY_TO, maxTimeoutSeconds: 60 }, +] + +describe('param constructors', () => { + it('puts the description in the schema and defaults to required', () => { + const p = param.string('The base asset, as CODE:ISSUER or XLM.') + expect(p.schema).toMatchObject({ type: 'string', description: 'The base asset, as CODE:ISSUER or XLM.' }) + expect(p.required).toBe(true) + }) + + it('carries examples and defaults through so an agent has a value to copy', () => { + const p = param.integer('Averaging window in minutes, 1 to 1440.', { required: false, default: 60, example: 120 }) + expect(p.required).toBe(false) + expect(p.schema).toMatchObject({ type: 'integer', default: 60, examples: [120] }) + }) + + it('emits an enum for a closed set of values', () => { + const p = param.enumOf(['1m', '1h'], 'Candle bucket size, one of the listed intervals.') + expect(p.schema.enum).toEqual(['1m', '1h']) + }) +}) + +describe('declareHttpResource', () => { + const pathParams = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } + + it('assembles path params into a JSON Schema object with required names', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/price/:assetA', + method: 'GET', + network: 'testnet', + description: 'Unified Stellar price for an asset pair.', + accepts, + pathParams, + }) + const input = listing.bazaar.info.input as { pathParams: Record } + expect(input.pathParams).toMatchObject({ + type: 'object', + additionalProperties: false, + required: ['assetA'], + properties: { assetA: { type: 'string', description: 'Base asset, as "XLM" or "CODE:ISSUER".' } }, + }) + }) + + it('fills the CAIP-2 network on accepts from the listing network', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/pools', + method: 'GET', + network: 'mainnet', + description: 'AMM liquidity pool reserves and spot prices.', + accepts, + }) + expect(listing.accepts[0].network).toBe('stellar:pubnet') + }) + + it('does not overwrite an explicitly supplied accepts network', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/pools', + method: 'GET', + network: 'mainnet', + description: 'AMM liquidity pool reserves and spot prices.', + accepts: [{ ...accepts[0], network: 'stellar:testnet' }], + }) + expect(listing.accepts[0].network).toBe('stellar:testnet') + }) + + it('uses body and bodyType for methods that carry one, not queryParams', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/graphql', + method: 'POST', + network: 'testnet', + description: 'GraphQL queries over Lens price and market data.', + accepts, + body: { query: param.string('The GraphQL query document to execute.') }, + }) + const input = listing.bazaar.info.input as Record + expect(input.bodyType).toBe('application/json') + expect(input.body).toBeDefined() + expect(input.queryParams).toBeUndefined() + }) + + it('omits an empty parameter set rather than emitting an empty schema', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/pools', + method: 'GET', + network: 'testnet', + description: 'AMM liquidity pool reserves and spot prices.', + accepts, + queryParams: {}, + }) + expect((listing.bazaar.info.input as Record).queryParams).toBeUndefined() + }) +}) + +describe('validateDeclaration', () => { + const good = { + url: 'https://lens.example/price/:assetA', + method: 'GET' as const, + network: 'testnet' as const, + description: 'Unified Stellar price for an asset pair.', + accepts, + } + + it('accepts a well-formed declaration', () => { + const pathParams = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } + const result = validateDeclaration(declareHttpResource({ ...good, pathParams }), { pathParams }) + expect(result.ok).toBe(true) + }) + + it('rejects a parameter with no description — the whole point of the helpers', () => { + const pathParams: Record = { + assetA: { schema: { type: 'string' }, required: true }, + } + const result = validateDeclaration(declareHttpResource({ ...good, pathParams }), { pathParams }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems).toContainEqual( + expect.objectContaining({ field: 'pathParams.assetA', code: 'missing_param_description' }), + ) + }) + + it('rejects a description too short to select on', () => { + const pathParams = { assetA: param.string('asset') } + const result = validateDeclaration(declareHttpResource({ ...good, pathParams }), { pathParams }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems).toContainEqual( + expect.objectContaining({ field: 'pathParams.assetA', code: 'param_description_too_short' }), + ) + }) + + it('rejects a weak resource description', () => { + const result = validateDeclaration(declareHttpResource({ ...good, description: 'prices' })) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems).toContainEqual( + expect.objectContaining({ field: 'resource.description', code: 'weak_resource_description' }), + ) + }) + + it('rejects a routeTemplate naming a parameter nobody declared', () => { + const pathParams = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } + const listing = declareHttpResource({ ...good, pathParams, routeTemplate: '/price/:assetA/:assetB' }) + const result = validateDeclaration(listing, { pathParams, routeTemplate: '/price/:assetA/:assetB' }) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems).toContainEqual( + expect.objectContaining({ code: 'undeclared_route_param', field: 'routeTemplate' }), + ) + }) + + it('rejects a listing with no payment requirement', () => { + const result = validateDeclaration(declareHttpResource({ ...good, accepts: [] })) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems).toContainEqual(expect.objectContaining({ code: 'missing_accepts' })) + }) + + it('surfaces the catalog’s own drops, so local and remote agree', () => { + // http:// is rejected by validateListing; the seller should hear it here. + const result = validateDeclaration(declareHttpResource({ ...good, url: 'http://lens.example/price' })) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.problems.some((p) => p.field === 'resource.url')).toBe(true) + }) +}) + +describe('assertDeclaration', () => { + it('returns the listing when it is valid', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/pools', + method: 'GET', + network: 'testnet', + description: 'AMM liquidity pool reserves and spot prices.', + accepts, + }) + expect(assertDeclaration(listing)).toBe(listing) + }) + + it('throws naming the field and code, so a bad listing fails the boot', () => { + const listing = declareHttpResource({ + url: 'https://lens.example/pools', + method: 'GET', + network: 'testnet', + description: 'pools', + accepts, + }) + expect(() => assertDeclaration(listing)).toThrow(/resource\.description.*weak_resource_description/s) + }) +}) + +describe('declareMcpTool', () => { + it('builds an mcp input with the tool name and a described input schema', () => { + const input = { assetA: param.string('Base asset, as "XLM" or "CODE:ISSUER".') } + const listing = declareMcpTool({ + url: 'https://lens.example/mcp', + network: 'testnet', + description: 'Unified Stellar price for an asset pair.', + accepts, + toolName: 'get_price', + transport: 'streamable-http', + input, + }) + expect(listing.type).toBe('mcp') + expect(listing.bazaar.info.input).toMatchObject({ + type: 'mcp', + toolName: 'get_price', + transport: 'streamable-http', + inputSchema: { properties: { assetA: { description: expect.any(String) } } }, + }) + }) + + it('emits an empty object schema for a tool that takes no arguments', () => { + const listing = declareMcpTool({ + url: 'https://lens.example/mcp', + network: 'testnet', + description: 'Health and indexer status for the Lens deployment.', + accepts, + toolName: 'get_status', + }) + expect((listing.bazaar.info.input as { inputSchema: Record }).inputSchema).toMatchObject({ + type: 'object', + additionalProperties: false, + }) + }) +}) + +describe('Lens dogfoods its own helpers', () => { + const listings = lensListings({ baseUrl: 'https://lens.example', network: 'testnet', accepts }) + + it('declares every gated route, and each one validates', () => { + // lensListings() runs assertDeclaration internally, so reaching here at all + // means they passed. Assert the coverage explicitly anyway. + const urls = listings.map((l) => l.resource.url) + expect(urls).toEqual([ + 'https://lens.example/price/:assetA/:assetB', + 'https://lens.example/candles/:assetA/:assetB', + 'https://lens.example/pools', + 'https://lens.example/price/twap/:assetA/:assetB', + ]) + }) + + it('gives every declared parameter a description', () => { + for (const listing of listings) { + const input = listing.bazaar.info.input as Record + for (const key of ['pathParams', 'queryParams', 'body']) { + const schema = input[key] + if (!schema) continue + for (const [name, prop] of Object.entries(schema.properties as Record)) { + expect(typeof prop.description, `${listing.resource.url} ${key}.${name}`).toBe('string') + expect((prop.description as string).length).toBeGreaterThan(11) + } + } + } + }) + + it('prices every listing on the declared network', () => { + for (const listing of listings) { + expect(listing.accepts.length).toBeGreaterThan(0) + expect(listing.accepts[0].network).toBe('stellar:testnet') + } + }) + + it('declares the MCP face of the price feed too', () => { + const mcp = lensMcpListing({ url: 'https://lens.example/mcp', network: 'testnet', accepts }) + expect(mcp.type).toBe('mcp') + expect((mcp.bazaar.info.input as { toolName: string }).toolName).toBe('get_price') + }) +}) diff --git a/src/bazaar/declare.ts b/src/bazaar/declare.ts new file mode 100644 index 0000000..a07ff68 --- /dev/null +++ b/src/bazaar/declare.ts @@ -0,0 +1,317 @@ +import type { NetworkName } from '../config' +import { X402_NETWORK_LABEL } from '../x402/network' +import { validateListing, type CatalogDrop } from './validation' +import type { + BazaarExtensionDeclaration, + BazaarHttpInput, + BazaarMcpInput, + BazaarPaymentRequirement, + BazaarResourceInfo, + RegisterBazaarResourceInput, +} from './types' + +/** + * Seller-side helpers for declaring discovery metadata (#134). + * + * The buyer is software. An agent choosing between two price feeds cannot open + * your docs — it has only what the listing declares. So the one thing this + * module refuses to let you skip is a description on every parameter: a + * declaration without them does not validate, it is not a warning. Ranking + * (#129) can only rank on what is declared, which caps search quality at the + * quality of the worst metadata in the catalog. + * + * Everything here is pure and synchronous. Validation reuses the catalog's own + * `validateListing`, so a listing that passes locally is one the catalog will + * accept for the same reasons — the seller finds out at development time + * instead of from an `EXTENSION-RESPONSES` header in production. + */ + +/** A single declared parameter: a JSON Schema fragment plus its requiredness. */ +export interface DeclaredParam { + schema: Record + required: boolean +} + +export interface ParamOptions { + /** Defaults to true. Optional parameters stay out of the schema's `required`. */ + required?: boolean + /** A concrete value an agent can copy. Worth more than another sentence of prose. */ + example?: unknown + default?: unknown +} + +function build( + type: string, + description: string, + options: ParamOptions = {}, + extra: Record = {}, +): DeclaredParam { + const schema: Record = { type, description, ...extra } + if (options.example !== undefined) schema.examples = [options.example] + if (options.default !== undefined) schema.default = options.default + return { schema, required: options.required ?? true } +} + +/** + * Parameter constructors. `description` is the first positional argument on + * every one of them, so it cannot be forgotten by accident — the signature is + * the enforcement. + */ +export const param = { + string: (description: string, options?: ParamOptions) => build('string', description, options), + number: (description: string, options?: ParamOptions) => build('number', description, options), + integer: (description: string, options?: ParamOptions) => build('integer', description, options), + boolean: (description: string, options?: ParamOptions) => build('boolean', description, options), + enumOf: (values: readonly string[], description: string, options?: ParamOptions) => + build('string', description, options, { enum: [...values] }), +} + +/** Assembles declared parameters into a JSON Schema object. */ +function toJsonSchema(params: Record | undefined): Record | undefined { + if (!params || Object.keys(params).length === 0) return undefined + const properties: Record = {} + const required: string[] = [] + for (const [name, declared] of Object.entries(params)) { + properties[name] = declared.schema + if (declared.required) required.push(name) + } + const schema: Record = { type: 'object', properties, additionalProperties: false } + if (required.length > 0) schema.required = required + return schema +} + +interface CommonSpec { + network: NetworkName + description: string + serviceName?: string + tags?: string[] + iconUrl?: string + /** Payment terms. `network` is filled in from the CAIP-2 id when omitted. */ + accepts: SellerAccept[] + output?: Record + extensionKeys?: string[] +} + +/** `BazaarPaymentRequirement` with the CAIP-2 `network` made optional, since + * the seller already stated which network the listing is for. */ +export type SellerAccept = Omit & { network?: string } + +export interface DeclareHttpSpec extends CommonSpec { + url: string + method: BazaarHttpInput['method'] + mimeType?: string + pathParams?: Record + queryParams?: Record + /** Body parameters. Only meaningful for POST/PUT/PATCH. */ + body?: Record + bodyType?: string + /** e.g. "/price/:base/:quote". Its `:params` must all be declared in `pathParams`. */ + routeTemplate?: string +} + +export interface DeclareMcpSpec extends CommonSpec { + url: string + toolName: string + input?: Record + transport?: BazaarMcpInput['transport'] +} + +const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']) + +function resolveAccepts(accepts: SellerAccept[], network: NetworkName): BazaarPaymentRequirement[] { + const caip2 = X402_NETWORK_LABEL[network] + return (accepts ?? []).map((accept) => ({ ...accept, network: accept.network ?? caip2 })) +} + +function resourceInfo(spec: CommonSpec & { url: string; mimeType?: string }): BazaarResourceInfo { + const resource: BazaarResourceInfo = { url: spec.url, description: spec.description } + if (spec.mimeType !== undefined) resource.mimeType = spec.mimeType + if (spec.serviceName !== undefined) resource.serviceName = spec.serviceName + if (spec.tags !== undefined) resource.tags = spec.tags + if (spec.iconUrl !== undefined) resource.iconUrl = spec.iconUrl + return resource +} + +/** Declares a priced HTTP endpoint. */ +export function declareHttpResource(spec: DeclareHttpSpec): RegisterBazaarResourceInput { + const wantsBody = BODY_METHODS.has(spec.method) + const input = { + type: 'http', + method: spec.method, + ...(spec.pathParams ? { pathParams: toJsonSchema(spec.pathParams) } : {}), + ...(wantsBody + ? { bodyType: spec.bodyType ?? 'application/json', ...(spec.body ? { body: toJsonSchema(spec.body) } : {}) } + : { ...(spec.queryParams ? { queryParams: toJsonSchema(spec.queryParams) } : {}) }), + } as BazaarHttpInput + + const bazaar: BazaarExtensionDeclaration = { + info: { input, ...(spec.output ? { output: spec.output } : {}) }, + schema: schemaFor(spec.pathParams, wantsBody ? spec.body : spec.queryParams, spec.output), + ...(spec.routeTemplate ? { routeTemplate: spec.routeTemplate } : {}), + } + + return { + type: 'http', + network: spec.network, + resource: resourceInfo({ ...spec, mimeType: spec.mimeType ?? 'application/json' }), + accepts: resolveAccepts(spec.accepts, spec.network), + bazaar, + ...(spec.extensionKeys ? { extensionKeys: spec.extensionKeys } : {}), + } +} + +/** Declares a priced MCP tool. */ +export function declareMcpTool(spec: DeclareMcpSpec): RegisterBazaarResourceInput { + const input: BazaarMcpInput = { + type: 'mcp', + toolName: spec.toolName, + inputSchema: toJsonSchema(spec.input) ?? { type: 'object', properties: {}, additionalProperties: false }, + ...(spec.transport ? { transport: spec.transport } : {}), + } + + const bazaar: BazaarExtensionDeclaration = { + info: { input, ...(spec.output ? { output: spec.output } : {}) }, + schema: schemaFor(undefined, spec.input, spec.output), + } + + return { + type: 'mcp', + network: spec.network, + resource: resourceInfo(spec), + accepts: resolveAccepts(spec.accepts, spec.network), + bazaar, + ...(spec.extensionKeys ? { extensionKeys: spec.extensionKeys } : {}), + } +} + +function schemaFor( + pathParams: Record | undefined, + inputParams: Record | undefined, + output: Record | undefined, +): Record { + const properties: Record = {} + const path = toJsonSchema(pathParams) + const body = toJsonSchema(inputParams) + if (path) properties.path = path + if (body) properties.input = body + if (output) properties.output = output + return { type: 'object', properties, additionalProperties: false } +} + +// --------------------------------------------------------------------------- +// Local validation +// --------------------------------------------------------------------------- + +export type DeclarationProblem = CatalogDrop + +export type DeclarationResult = + | { ok: true; listing: RegisterBazaarResourceInput; problems: [] } + | { ok: false; problems: DeclarationProblem[] } + +/** Shortest description that says anything an agent can select on. */ +const MIN_DESCRIPTION = 12 + +function problem(field: string, code: string, message: string): DeclarationProblem { + return { field, code, message } +} + +function checkParamDescriptions( + params: Record | undefined, + path: string, + problems: DeclarationProblem[], +): void { + if (!params) return + for (const [name, declared] of Object.entries(params)) { + const description = declared.schema.description + if (typeof description !== 'string' || description.trim().length === 0) { + problems.push( + problem( + `${path}.${name}`, + 'missing_param_description', + 'Every parameter needs a description. An agent choosing between endpoints has only what the listing declares.', + ), + ) + } else if (description.trim().length < MIN_DESCRIPTION) { + problems.push( + problem( + `${path}.${name}`, + 'param_description_too_short', + `Description is under ${MIN_DESCRIPTION} characters — say what the parameter accepts, not just its name.`, + ), + ) + } + } +} + +/** + * Validates a declaration locally, before anything is sent. + * + * Runs the catalog's own `validateListing` — so anything the catalog would soft + * drop is caught here first, with the same field paths and codes — and adds the + * seller-side quality rules the catalog cannot enforce, because by then the + * metadata is all it has. + */ +export function validateDeclaration( + listing: RegisterBazaarResourceInput, + spec?: { pathParams?: Record; queryParams?: Record; body?: Record; input?: Record; routeTemplate?: string }, +): DeclarationResult { + const problems: DeclarationProblem[] = [] + + const payTo = listing.accepts?.[0]?.payTo + if (!payTo) { + problems.push(problem('accepts', 'missing_accepts', 'At least one payment requirement is needed — a listing with no price is not discoverable as a paid endpoint.')) + } + + const description = listing.resource?.description + if (typeof description !== 'string' || description.trim().length < MIN_DESCRIPTION) { + problems.push(problem('resource.description', 'weak_resource_description', `The resource needs a description of at least ${MIN_DESCRIPTION} characters. This is the field an agent ranks on.`)) + } + + if (spec) { + checkParamDescriptions(spec.pathParams, 'pathParams', problems) + checkParamDescriptions(spec.queryParams, 'queryParams', problems) + checkParamDescriptions(spec.body, 'body', problems) + checkParamDescriptions(spec.input, 'input', problems) + + // A route template naming a parameter nobody declared is a listing an agent + // cannot call: it knows the URL shape but not what goes in the slot. + if (spec.routeTemplate) { + const declared = new Set(Object.keys(spec.pathParams ?? {})) + for (const segment of spec.routeTemplate.split('/')) { + if (segment.startsWith(':')) { + const name = segment.slice(1) + if (!declared.has(name)) { + problems.push(problem('routeTemplate', 'undeclared_route_param', `routeTemplate names ":${name}" but no such entry exists in pathParams.`)) + } + } + } + } + } + + if (payTo) { + const catalog = validateListing(listing, { payTo, network: listing.network }) + if (!catalog.ok) problems.push(...catalog.drops) + } + + if (problems.length > 0) return { ok: false, problems } + return { ok: true, listing, problems: [] } +} + +/** + * Validates and returns the listing, throwing on any problem. + * + * Call this at module load on your own declarations: a malformed listing then + * fails the build or the boot, which is the point — an endpoint that silently + * never appears in the Bazaar is the failure this is designed to prevent. + */ +export function assertDeclaration( + listing: RegisterBazaarResourceInput, + spec?: Parameters[1], +): RegisterBazaarResourceInput { + const result = validateDeclaration(listing, spec) + if (!result.ok) { + const lines = result.problems.map((p) => ` - ${p.field} [${p.code}]: ${p.message}`) + throw new Error(`Invalid Bazaar declaration for ${listing.resource?.url ?? '(no url)'}:\n${lines.join('\n')}`) + } + return result.listing +} diff --git a/src/bazaar/lensListings.ts b/src/bazaar/lensListings.ts new file mode 100644 index 0000000..1300f77 --- /dev/null +++ b/src/bazaar/lensListings.ts @@ -0,0 +1,182 @@ +import type { NetworkName } from '../config' +import { assertDeclaration, declareHttpResource, declareMcpTool, param } from './declare' +import type { RegisterBazaarResourceInput } from './types' +import type { DeclaredParam, SellerAccept } from './declare' + +/** + * Lens's own gated endpoints, declared with the seller helpers (#134). + * + * Dogfooding, deliberately: `src/middleware/x402.ts` gates `/price`, `/pools` + * and `/candles`, so these are the first listings in our own Bazaar. If + * declaring metadata for our own price feed were awkward, it would be awkward + * for everyone else, and this is where we would find that out. + * + * Each declaration goes through `assertDeclaration` at module load, so a listing + * that would be soft-dropped by the catalog fails the boot instead of silently + * never appearing in the Bazaar. + */ + +/** Shared by every Lens listing: the asset pair in the path. */ +const assetPair: Record = { + assetA: param.string( + 'Base asset, as "XLM" for the native asset or "CODE:ISSUER" for a credit asset (e.g. "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN").', + { example: 'XLM' }, + ), + assetB: param.string( + 'Quote asset, in the same "XLM" or "CODE:ISSUER" form as assetA. The price returned is assetA denominated in assetB.', + { example: 'USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' }, + ), +} + +const priceOutput = { + type: 'object', + description: 'Aggregated price across SDEX order books and AMM pools, with the best execution route.', + properties: { + price: { type: 'string', description: 'Aggregated mid price of assetA in assetB, as a decimal string.' }, + network: { type: 'string', description: 'Stellar network the quote was computed on: "mainnet" or "testnet".' }, + bestRoute: { type: 'array', description: 'Ordered venue hops giving the best execution for this pair.' }, + lastUpdated: { type: 'string', description: 'ISO 8601 timestamp of the most recent underlying observation.' }, + }, +} as const + +const candlesOutput = { + type: 'object', + description: 'OHLCV candles for the pair over the requested window.', + properties: { + candles: { + type: 'array', + description: 'Candles in ascending time order, each with open, high, low, close, volume and a bucket start time.', + }, + }, +} as const + +const poolsOutput = { + type: 'object', + description: 'Liquidity pool reserves and spot prices for every AMM pool Lens indexes.', + properties: { + pools: { + type: 'array', + description: 'One entry per indexed pool, with its reserves, spot price and source protocol.', + }, + }, +} as const + +/** + * Builds the listings for one network and base URL. + * + * `accepts` comes from the caller rather than being read from env here, so this + * stays pure and testable — the payment address is the resource server's to + * know, not this module's. + */ +export function lensListings(options: { + baseUrl: string + network: NetworkName + accepts: SellerAccept[] +}): RegisterBazaarResourceInput[] { + const { baseUrl, network, accepts } = options + const base = baseUrl.replace(/\/+$/, '') + const common = { network, accepts, serviceName: 'Lens', tags: ['stellar', 'price-feed', 'defi'] } + + const price = declareHttpResource({ + ...common, + url: `${base}/price/:assetA/:assetB`, + method: 'GET', + routeTemplate: '/price/:assetA/:assetB', + description: + 'Unified Stellar price for an asset pair, aggregated across SDEX order books and AMM pools, with VWAP and the best execution route.', + pathParams: assetPair, + output: priceOutput, + }) + + const candles = declareHttpResource({ + ...common, + url: `${base}/candles/:assetA/:assetB`, + method: 'GET', + routeTemplate: '/candles/:assetA/:assetB', + description: 'OHLCV candle data for a Stellar asset pair, for charting and backtesting.', + pathParams: assetPair, + queryParams: { + interval: param.enumOf( + ['1m', '5m', '15m', '1h', '4h', '1d'], + 'Candle bucket size. Defaults to "1h" when omitted.', + { required: false, default: '1h' }, + ), + from: param.string('Window start, as an ISO 8601 timestamp or a Unix epoch in seconds. Defaults to the earliest candle available.', { + required: false, + example: '2026-08-01T00:00:00Z', + }), + to: param.string('Window end, as an ISO 8601 timestamp or a Unix epoch in seconds. Defaults to now.', { + required: false, + example: '2026-08-31T00:00:00Z', + }), + }, + output: candlesOutput, + }) + + const pools = declareHttpResource({ + ...common, + url: `${base}/pools`, + method: 'GET', + description: 'AMM liquidity pool reserves and spot prices across every pool Lens indexes on Stellar.', + output: poolsOutput, + }) + + const twap = declareHttpResource({ + ...common, + url: `${base}/price/twap/:assetA/:assetB`, + method: 'GET', + routeTemplate: '/price/twap/:assetA/:assetB', + description: 'Time-weighted average price for a Stellar asset pair, with outlier rejection — the manipulation-resistant reference an on-chain consumer should quote.', + pathParams: assetPair, + queryParams: { + window: param.integer('Averaging window in minutes, 1 to 1440. Defaults to 60.', { required: false, default: 60 }), + sampleInterval: param.integer('Seconds between samples inside the window, 1 to 3600. Defaults to 60.', { required: false, default: 60 }), + method: param.enumOf(['iqr', 'modified_zscore'], 'Outlier rejection method applied to samples before averaging. Defaults to "iqr".', { + required: false, + default: 'iqr', + }), + }, + output: priceOutput, + }) + + return [ + assertDeclaration(price, { pathParams: assetPair, routeTemplate: '/price/:assetA/:assetB' }), + assertDeclaration(candles, { pathParams: assetPair, queryParams: candlesQueryParams, routeTemplate: '/candles/:assetA/:assetB' }), + assertDeclaration(pools), + assertDeclaration(twap, { pathParams: assetPair, queryParams: twapQueryParams, routeTemplate: '/price/twap/:assetA/:assetB' }), + ] +} + +/** Declared once so the same objects can be handed to `assertDeclaration`. */ +const candlesQueryParams: Record = { + interval: param.enumOf(['1m', '5m', '15m', '1h', '4h', '1d'], 'Candle bucket size. Defaults to "1h" when omitted.', { required: false, default: '1h' }), + from: param.string('Window start, as an ISO 8601 timestamp or a Unix epoch in seconds. Defaults to the earliest candle available.', { required: false }), + to: param.string('Window end, as an ISO 8601 timestamp or a Unix epoch in seconds. Defaults to now.', { required: false }), +} + +const twapQueryParams: Record = { + window: param.integer('Averaging window in minutes, 1 to 1440. Defaults to 60.', { required: false, default: 60 }), + sampleInterval: param.integer('Seconds between samples inside the window, 1 to 3600. Defaults to 60.', { required: false, default: 60 }), + method: param.enumOf(['iqr', 'modified_zscore'], 'Outlier rejection method applied to samples before averaging. Defaults to "iqr".', { required: false, default: 'iqr' }), +} + +/** The MCP face of the same price feed, for agents that speak MCP rather than HTTP. */ +export function lensMcpListing(options: { url: string; network: NetworkName; accepts: SellerAccept[] }): RegisterBazaarResourceInput { + const input: Record = { + assetA: assetPair.assetA, + assetB: assetPair.assetB, + } + const listing = declareMcpTool({ + url: options.url, + network: options.network, + accepts: options.accepts, + serviceName: 'Lens', + tags: ['stellar', 'price-feed', 'mcp'], + toolName: 'get_price', + transport: 'streamable-http', + description: 'Unified Stellar price for an asset pair, aggregated across SDEX order books and AMM pools.', + input, + output: priceOutput as unknown as Record, + }) + return assertDeclaration(listing, { input }) +}