From fdf47fe5ed2c3fa7878fcdd7f15f434cef0dbae4 Mon Sep 17 00:00:00 2001 From: epistemedeus Date: Thu, 20 Aug 2026 15:30:47 -0700 Subject: [PATCH] fix(openapi): declare Base Fee response contract Signed-off-by: epistemedeus --- src/openapi.js | 31 ++++++- src/x402.js | 32 ++++++- test/openapi.test.js | 172 +++++++++++++++++++++++++++++++++++++- test/queries-fees.test.js | 18 ++++ 4 files changed, 247 insertions(+), 6 deletions(-) diff --git a/src/openapi.js b/src/openapi.js index d19e35e..c3de776 100644 --- a/src/openapi.js +++ b/src/openapi.js @@ -42,6 +42,34 @@ export function inputsToParameters(inputs) { }); } +// Project a seller discovery output schema into the OpenAPI 200 response +// only when it declares an explicit non-empty `required` array. Missing +// or property-only schemas keep the former `{ type: 'object' }` so +// nullable declared scalars cannot fail strict 200 validation. Never +// infer requiredness from property names. When projecting, copy only +// the property definitions named by that required array plus the exact +// required array — optional declared properties stay out of OpenAPI. +export function responseSchemaFromOutput(schema) { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { + return { type: 'object' }; + } + if (!Array.isArray(schema.required) || schema.required.length === 0) { + return { type: 'object' }; + } + const copy = { ...schema }; + if (copy.properties && typeof copy.properties === 'object' && !Array.isArray(copy.properties)) { + const properties = {}; + for (const name of schema.required) { + if (Object.prototype.hasOwnProperty.call(copy.properties, name)) { + properties[name] = copy.properties[name]; + } + } + copy.properties = properties; + } + copy.required = [...schema.required]; + return copy; +} + // Hand-rolled request schemas for the POST routes. These mirror the // zod validation in the gateway handlers; kept compact deliberately — // agents need field names, types and required-ness, not every refine. @@ -122,12 +150,13 @@ export function buildOpenApiDocument({ x402Cfg, serviceVersion = '1.0.0' }) { const [method, path] = routeKey.split(' '); const lower = method.toLowerCase(); const accepts = entry?.accepts ?? {}; + const schema = responseSchemaFromOutput(entry?.discovery?.output?.schema); const operation = { operationId: routeKey.replace(/[^a-zA-Z0-9]+/gu, '_').replace(/^_+|_+$/gu, ''), summary: (accepts.description ?? '').split('.')[0] || path, description: accepts.description ?? '', responses: { - 200: { description: 'Paid response', content: { 'application/json': { schema: { type: 'object' } } } }, + 200: { description: 'Paid response', content: { 'application/json': { schema } } }, 402: { description: 'Payment Required' } }, 'x-payment-info': { diff --git a/src/x402.js b/src/x402.js index c9d1700..5ddc95e 100644 --- a/src/x402.js +++ b/src/x402.js @@ -44,6 +44,30 @@ const QI = Object.freeze({ builder: { type: 'string', description: 'Builder name (substring match).' } }); +const BASE_FEE_OUTPUT_REQUIRED = Object.freeze(['as_of_ms', 'block', 'source']); + +// qFact freezes the descriptor. Copy it so GET /v1/q/base-fee can declare +// seller-owned `required` on output.schema without mutating frozen objects +// or the payments-gateway package. +function withOutputRequired(route, required) { + const discovery = route.discovery ?? {}; + const output = discovery.output ?? {}; + const schema = output.schema ?? { type: 'object' }; + return Object.freeze({ + ...route, + discovery: Object.freeze({ + ...discovery, + output: { + ...output, + schema: { + ...schema, + required: Object.freeze([...required]) + } + } + }) + }); +} + export const PREMIUM_ROUTES = Object.freeze([ // === Payments gateway: Private Watch + privacy-chain facts === ...GATEWAY_PREMIUM_ROUTES, @@ -221,12 +245,12 @@ export const PREMIUM_ROUTES = Object.freeze([ outputProps: { as_of_ms: { type: 'integer' }, block: { type: 'integer' }, ts_ms: { type: 'integer' }, proposer_payment_eth: { type: 'number' }, proposer_payment_wei: { type: 'string' }, builder: { type: 'string' }, tx_count: { type: 'integer' }, hour: { type: 'object' }, source_freshness_s: { type: 'integer' }, history_endpoint: { type: 'string' } }, outputExample: { as_of_ms: 1765532000000, block: 23012001, ts_ms: 1765531988000, proposer_payment_eth: 0.010354286, proposer_payment_wei: '10354286568285197', builder: 'titan', tx_count: 309, hour: { samples: 291, mean_eth: 0.0182, median_eth: 0.0121, p90_eth: 0.0388, max_eth: 0.41 }, source_freshness_s: 9, history_endpoint: '/v1/premium/builder-bids.csv' } }), - qFact('/v1/q/base-fee', 'Single-fact: what does Ethereum mainnet gas cost RIGHT NOW? Returns the latest block\u2019s base fee + blob base fee AND the consensus-computed next-block projection (not a guess — the protocol\u2019s own EIP-1559/4844 arithmetic), straight from eth_feeHistory. Fields: {block, base_fee_gwei, blob_base_fee_gwei, gas_used_ratio, next_base_fee_gwei, next_blob_base_fee_gwei}. Bulk per-block history: /v1/premium/fee-history.csv ($0.05, up to 1024 blocks/call).', { + withOutputRequired(qFact('/v1/q/base-fee', 'Single-fact: what does Ethereum mainnet gas cost RIGHT NOW? Returns the latest block\u2019s base fee + blob base fee AND the consensus-computed next-block projection (not a guess — the protocol\u2019s own EIP-1559/4844 arithmetic), straight from eth_feeHistory. Fields: {block, base_fee_gwei, blob_base_fee_gwei, gas_used_ratio, next_base_fee_gwei, next_blob_base_fee_gwei}. Bulk per-block history: /v1/premium/fee-history.csv ($0.05, up to 1024 blocks/call).', { inputSchema: { type: 'object', properties: {} }, inputExample: {}, - outputProps: { as_of_ms: { type: 'integer' }, block: { type: 'integer' }, base_fee_wei: { type: 'string' }, base_fee_gwei: { type: 'number' }, gas_used_ratio: { type: 'number' }, blob_base_fee_wei: { type: 'string' }, blob_base_fee_gwei: { type: 'number' }, blob_gas_used_ratio: { type: 'number' }, next_block: { type: 'integer' }, next_base_fee_gwei: { type: 'number' }, next_blob_base_fee_gwei: { type: 'number' } }, - outputExample: { as_of_ms: 1765532000000, block: 23012001, base_fee_wei: '747213960', base_fee_gwei: 0.74721396, gas_used_ratio: 0.4993, blob_base_fee_wei: '1', blob_base_fee_gwei: 0.000000001, blob_gas_used_ratio: 0.25, next_block: 23012002, next_base_fee_gwei: 0.747189, next_blob_base_fee_gwei: 0.000000001 } - }) + outputProps: { as_of_ms: { type: 'integer' }, block: { type: 'integer' }, base_fee_wei: { type: 'string' }, base_fee_gwei: { type: 'number' }, gas_used_ratio: { type: 'number' }, blob_base_fee_wei: { type: 'string' }, blob_base_fee_gwei: { type: 'number' }, blob_gas_used_ratio: { type: 'number' }, next_block: { type: 'integer' }, next_base_fee_gwei: { type: 'number' }, next_blob_base_fee_gwei: { type: 'number' }, source: { type: 'string' } }, + outputExample: { as_of_ms: 1765532000000, block: 23012001, base_fee_wei: '747213960', base_fee_gwei: 0.74721396, gas_used_ratio: 0.4993, blob_base_fee_wei: '1', blob_base_fee_gwei: 0.000000001, blob_gas_used_ratio: 0.25, next_block: 23012002, next_base_fee_gwei: 0.747189, next_blob_base_fee_gwei: 0.000000001, source: 'eth_feeHistory' } + }), BASE_FEE_OUTPUT_REQUIRED) ]); /** diff --git a/test/openapi.test.js b/test/openapi.test.js index 9d91dd9..0991a1b 100644 --- a/test/openapi.test.js +++ b/test/openapi.test.js @@ -6,7 +6,7 @@ import { describe, test, expect, beforeAll } from '@jest/globals'; -import { buildOpenApiDocument, priceToAmount, inputsToParameters } from '../src/openapi.js'; +import { buildOpenApiDocument, priceToAmount, inputsToParameters, responseSchemaFromOutput } from '../src/openapi.js'; import { buildX402Config } from '../src/x402.js'; const ENV = { @@ -43,6 +43,68 @@ describe('inputsToParameters', () => { }); }); +describe('responseSchemaFromOutput', () => { + test('empty / missing schema → untyped object', () => { + expect(responseSchemaFromOutput(undefined)).toEqual({ type: 'object' }); + expect(responseSchemaFromOutput(null)).toEqual({ type: 'object' }); + expect(responseSchemaFromOutput({})).toEqual({ type: 'object' }); + }); + + test('copies only required property definitions plus the exact required array', () => { + const declared = { + type: 'object', + properties: { + as_of_ms: { type: 'integer' }, + block: { type: 'integer' }, + source: { type: 'string' }, + blob_base_fee_gwei: { type: 'number' }, + base_fee_gwei: { type: 'number' } + }, + required: ['as_of_ms', 'block', 'source'], + additionalProperties: true, + description: 'base-fee fact' + }; + const schema = responseSchemaFromOutput(declared); + expect(schema.type).toBe('object'); + expect(Object.keys(schema.properties)).toEqual(['as_of_ms', 'block', 'source']); + expect(schema.properties.as_of_ms).toEqual({ type: 'integer' }); + expect(schema.properties.block).toEqual({ type: 'integer' }); + expect(schema.properties.source).toEqual({ type: 'string' }); + expect(schema.properties.blob_base_fee_gwei).toBeUndefined(); + expect(schema.properties.base_fee_gwei).toBeUndefined(); + expect(schema.required).toEqual(['as_of_ms', 'block', 'source']); + expect(schema.required).not.toContain('blob_base_fee_gwei'); + expect(schema.additionalProperties).toBe(true); + expect(schema.description).toBe('base-fee fact'); + }); + + test('property-only schemas keep the former untyped object', () => { + expect(responseSchemaFromOutput({ + type: 'object', + properties: { + as_of_ms: { type: 'integer' }, + block: { type: 'integer' }, + source: { type: 'string' } + } + })).toEqual({ type: 'object' }); + }); + + test('empty required array keeps the former untyped object', () => { + expect(responseSchemaFromOutput({ + type: 'object', + properties: { as_of_ms: { type: 'integer' } }, + required: [] + })).toEqual({ type: 'object' }); + }); + + test('string CSV/JSONL schemas without required stay untyped objects', () => { + expect(responseSchemaFromOutput({ + type: 'string', + description: 'text/csv or application/jsonl; one row per block.' + })).toEqual({ type: 'object' }); + }); +}); + describe('buildOpenApiDocument (paywall enabled)', () => { let doc; beforeAll(() => { @@ -108,6 +170,61 @@ describe('buildOpenApiDocument (paywall enabled)', () => { expect(required).toEqual(['builder']); }); + test('GET /v1/q/base-fee 200 schema property keys are exactly as_of_ms, block, source', () => { + const content = doc.paths['/v1/q/base-fee'].get.responses['200'].content; + expect(Object.keys(content)).toEqual(['application/json']); + const schema = content['application/json'].schema; + expect(schema.type).toBe('object'); + expect(Object.keys(schema.properties)).toEqual(['as_of_ms', 'block', 'source']); + expect(schema.properties.as_of_ms).toEqual({ type: 'integer' }); + expect(schema.properties.block).toEqual({ type: 'integer' }); + expect(schema.properties.source).toEqual({ type: 'string' }); + expect(schema.required).toEqual(['as_of_ms', 'block', 'source']); + expect(schema.properties.blob_base_fee_gwei).toBeUndefined(); + expect(schema.properties.blob_base_fee_wei).toBeUndefined(); + expect(schema.properties.base_fee_gwei).toBeUndefined(); + expect(schema.properties.cache_hit).toBeUndefined(); + expect(schema.required).not.toContain('blob_base_fee_gwei'); + expect(schema.required).not.toContain('base_fee_gwei'); + expect(schema.required).not.toContain('cache_hit'); + }); + + test('GET /v1/premium/fee-history.csv 200 keeps former untyped object under application/json', () => { + const content = doc.paths['/v1/premium/fee-history.csv'].get.responses['200'].content; + expect(Object.keys(content)).toEqual(['application/json']); + expect(content['application/json'].schema).toEqual({ type: 'object' }); + expect(content['text/csv']).toBeUndefined(); + expect(content['application/jsonl']).toBeUndefined(); + }); + + test('proposer-payment, data-freshness, and opportunities keep former untyped object', () => { + const former = { type: 'object' }; + const proposer = doc.paths['/v1/q/proposer-payment'].get.responses['200'].content; + expect(Object.keys(proposer)).toEqual(['application/json']); + expect(proposer['application/json'].schema).toEqual(former); + + const freshness = doc.paths['/v1/q/data-freshness'].get.responses['200'].content; + expect(Object.keys(freshness)).toEqual(['application/json']); + expect(freshness['application/json'].schema).toEqual(former); + + const opportunities = doc.paths['/v1/premium/opportunities'].get.responses['200'].content; + expect(Object.keys(opportunities)).toEqual(['application/json']); + expect(opportunities['application/json'].schema).toEqual(former); + }); + + test('no unrelated paid 200 strengthens beyond former untyped object under application/json', () => { + const former = { type: 'object' }; + for (const [path, methods] of Object.entries(doc.paths)) { + for (const [method, op] of Object.entries(methods)) { + if (!op['x-payment-info']) continue; + if (path === '/v1/q/base-fee' && method === 'get') continue; + const content = op.responses['200'].content; + expect(Object.keys(content)).toEqual(['application/json']); + expect(content['application/json'].schema).toEqual(former); + } + } + }); + test('free starter routes are present without x-payment-info', () => { for (const p of ['/v1/q', '/v1/paywall', '/v1/private/info']) { expect(doc.paths[p].get).toBeDefined(); @@ -140,3 +257,56 @@ describe('buildOpenApiDocument (paywall disabled)', () => { expect(paidPaths).toEqual([]); }); }); + +describe('buildOpenApiDocument (explicit required only)', () => { + test('property-only paid route keeps former untyped object and application/json even if mimeType is csv', () => { + const doc = buildOpenApiDocument({ + x402Cfg: { + enabled: true, + routes: { + 'GET /synthetic/props-only': { + accepts: { description: 'synthetic unpaid-shape route', price: '$0.001' }, + mimeType: 'text/csv', + discovery: { output: { schema: { type: 'object', properties: { ok: { type: 'boolean' } } } } } + } + } + } + }); + const content = doc.paths['/synthetic/props-only'].get.responses['200'].content; + expect(Object.keys(content)).toEqual(['application/json']); + expect(content['application/json'].schema).toEqual({ type: 'object' }); + }); + + test('explicit required schema is projected under application/json without optional properties', () => { + const doc = buildOpenApiDocument({ + x402Cfg: { + enabled: true, + routes: { + 'GET /synthetic/required': { + accepts: { description: 'synthetic unpaid-shape route', price: '$0.001' }, + mimeType: 'text/csv', + discovery: { + output: { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + blob_base_fee_gwei: { type: 'number' } + }, + required: ['id'] + } + } + } + } + } + } + }); + const content = doc.paths['/synthetic/required'].get.responses['200'].content; + expect(Object.keys(content)).toEqual(['application/json']); + expect(content['application/json'].schema).toEqual({ + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'] + }); + }); +}); diff --git a/test/queries-fees.test.js b/test/queries-fees.test.js index a00b8d0..44a696e 100644 --- a/test/queries-fees.test.js +++ b/test/queries-fees.test.js @@ -164,7 +164,9 @@ describe('qBaseFee', () => { test('returns the latest block plus the next-block projection', async () => { const _fetch = mockFetch(cannedResult()); const fact = await qBaseFee({}, { rpcUrl: 'http://rpc', nowMs: 1000, _cache: new Map(), _fetch }); + expect(fact.as_of_ms).toBe(1000); expect(fact.block).toBe(258); + expect(fact.source).toBe('eth_feeHistory'); expect(fact.base_fee_wei).toBe('960000000'); expect(fact.base_fee_gwei).toBeCloseTo(0.96, 6); expect(fact.next_block).toBe(259); @@ -176,6 +178,22 @@ describe('qBaseFee', () => { expect(_fetch.calls[0].body.params[0]).toBe('0x1'); }); + test('blob-less RPC still returns typed as_of_ms, block, source', async () => { + const _fetch = mockFetch(cannedResult({ withBlobs: false })); + const fact = await qBaseFee({}, { rpcUrl: 'http://rpc', nowMs: 1000, _cache: new Map(), _fetch }); + expect(fact.as_of_ms).toBe(1000); + expect(Number.isInteger(fact.as_of_ms)).toBe(true); + expect(fact.block).toBe(258); + expect(Number.isInteger(fact.block)).toBe(true); + expect(fact.source).toBe('eth_feeHistory'); + expect(typeof fact.source).toBe('string'); + expect(fact.blob_base_fee_wei).toBeNull(); + expect(fact.blob_base_fee_gwei).toBeNull(); + expect(fact.blob_gas_used_ratio).toBeNull(); + expect(fact.next_blob_base_fee_wei).toBeNull(); + expect(fact.next_blob_base_fee_gwei).toBeNull(); + }); + test('serves from cache inside the TTL, refetches after it', async () => { const _fetch = mockFetch(cannedResult()); const _cache = new Map();