Skip to content
Open
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
31 changes: 30 additions & 1 deletion src/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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': {
Expand Down
32 changes: 28 additions & 4 deletions src/x402.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
]);

/**
Expand Down
172 changes: 171 additions & 1 deletion test/openapi.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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']
});
});
});
18 changes: 18 additions & 0 deletions test/queries-fees.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down