import * as TestApp from '../../../../test/App.js' import * as Runtime from '../../../../test/runtime.js' import * as Db from '../../../db/Db.js' import * as Log from '../../../internal/Log.js' import * as Schema from '../../../internal/Schema.js' import * as VerifiedTokens from '../../../internal/VerifiedTokens.js' import * as Tokenlist from './tokenlist.js' type JsonSchema = { /** Referenced component path. */ $ref?: string /** Alternative schemas accepted by this value. */ anyOf?: readonly JsonSchema[] /** Human-readable schema description. */ description?: string /** Allowed string values. */ enum?: readonly string[] /** Representative schema values. */ examples?: readonly unknown[] /** OpenAPI value format. */ format?: string /** Schema for array entries. */ items?: JsonSchema /** Schemas for object properties. */ properties?: Record /** Required object property names. */ required?: readonly string[] } type OpenApiMediaType = { /** Schema for this media type. */ schema?: JsonSchema } type OpenApiResponse = { /** Referenced response component path. */ $ref?: string /** Response bodies keyed by media type. */ content?: Record } type OpenApiOperation = { /** Stable generated-client method name. */ operationId?: string /** Responses keyed by HTTP status. */ responses?: Record } type OpenApiComponents = { /** Reusable schemas keyed by component name. */ schemas: Record } type OpenApiPath = { /** GET operation for this path. */ get?: OpenApiOperation } type OpenApiDocument = { /** Reusable OpenAPI components. */ components: OpenApiComponents /** Operations keyed by request path. */ paths: Record } /** Reader key for the data routes. */ const reader = { id: 'key_reader', orgId: 'org_test', scopes: ['data:read'], token: 'secret_reader', } satisfies TestApp.kvStore.Key /** A known list published into the store before exercising the read endpoint. */ const seedList = [ { address: '0x20c0000000000000000000000000000000001001', currency: 'USD', decimals: 6, logoUri: 'https://example.com/susd.svg', name: 'Seed USD', symbol: 'sUSD' }, // prettier-ignore { address: '0x20c0000000000000000000000000000000001002', currency: 'EUR', decimals: 2, name: 'Seed EUR', symbol: 'sEUR' }, // prettier-ignore ] as const const chainId = Runtime.get().chainId /** RequestInit carrying a bearer token for the given key. */ function as(key: { token: string }) { return { headers: { authorization: `Bearer ${key.token}` } } as const } /** Client whose verified-token store holds a published {@link seedList} snapshot. */ async function seeded() { const db = TestApp.database() await VerifiedTokens.replace(db, chainId, seedList) return TestApp.client({ auth: { keys: [reader] }, db, }) } function component(document: OpenApiDocument, name: string): JsonSchema { const value = document.components.schemas[name] if (!value) throw new Error(`Missing OpenAPI component ${name}`) return value } function resolveSchema(document: OpenApiDocument, value: JsonSchema | undefined): JsonSchema { if (!value) throw new Error('Missing OpenAPI schema') if (!value.$ref) return value const name = value.$ref.split('/').at(-1) if (!name) throw new Error(`Invalid OpenAPI component ref ${value.$ref}`) return component(document, name) } describe('OpenAPI', () => { test('publishes a generator-ready token list contract', async () => { const app = TestApp.create({ auth: false }) const document = (await (await app.request('/openapi.json')).json()) as OpenApiDocument const operation = document.paths['/v1/tokenlist']?.get if (!operation) throw new Error('Missing token-list OpenAPI operation') const errors = Object.fromEntries( [400, 401, 402, 403, 429, 500, 502, 504] .filter((status) => operation.responses?.[status]) .map((status) => { const response = operation.responses?.[status] if (response?.$ref) return [status, { $ref: response.$ref }] const schema = response?.content?.['application/json']?.schema return [ status, resolveSchema(document, schema).properties?.['error']?.properties?.['code']?.enum, ] }), ) const list = component(document, 'TokenListDocument') const token = component(document, 'TokenListToken') const version = component(document, 'TokenListVersion') expect({ components: { list: { description: list.description, required: list.required, tokenExamples: list.properties?.['tokens']?.examples, tokens: list.properties?.['tokens']?.items, version: { anyOf: list.properties?.['version']?.anyOf, examples: list.properties?.['version']?.examples, }, }, token: { description: token.description, logoFormat: token.properties?.['logoURI']?.format, required: token.required, }, version: { description: version.description, required: version.required }, }, operation: { errors, operationId: operation.operationId, response: operation.responses?.['200']?.content?.['application/json']?.schema, }, }).toMatchInlineSnapshot(` { "components": { "list": { "description": "A Uniswap Token Lists-compatible list of Tempo’s verified TIP-20 tokens.", "required": [ "name", "timestamp", "version", "tokens", ], "tokenExamples": [ [ { "address": "0x20c000000000000000000000b9537d11c60e8b50", "chainId": 4217, "decimals": 6, "logoURI": "https://api.tempo.xyz/assets/4217/icons/0x20c000000000000000000000b9537d11c60e8b50?__response=svg-sandbox-v1", "name": "Bridged USDC (Stargate)", "symbol": "USDC.e", }, ], ], "tokens": { "$ref": "#/components/schemas/TokenListToken", "examples": [ { "address": "0x20c000000000000000000000b9537d11c60e8b50", "chainId": 4217, "decimals": 6, "logoURI": "https://api.tempo.xyz/assets/4217/icons/0x20c000000000000000000000b9537d11c60e8b50?__response=svg-sandbox-v1", "name": "Bridged USDC (Stargate)", "symbol": "USDC.e", }, ], }, "version": { "anyOf": [ { "$ref": "#/components/schemas/TokenListVersion", }, ], "examples": [ { "major": 1, "minor": 0, "patch": 2, }, ], }, }, "token": { "description": "One token entry in the standard verified token list format.", "logoFormat": "uri", "required": [ "chainId", "address", "name", "symbol", "decimals", ], }, "version": { "description": "The token list’s semantic version.", "required": [ "major", "minor", "patch", ], }, }, "operation": { "errors": { "400": [ "api_key_malformed", "chain_id_invalid", "chain_id_unsupported", "query_invalid", ], "401": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "402": { "$ref": "#/components/responses/PaymentRequired", }, "403": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "429": { "$ref": "#/components/responses/RateLimited", }, "500": { "$ref": "#/components/responses/InternalError", }, "502": [ "upstream_error", ], "504": { "$ref": "#/components/responses/RequestTimeout", }, }, "operationId": "getTokenList", "response": { "$ref": "#/components/schemas/TokenListDocument", }, }, } `) }) test('returns the documented validation errors', async () => { const app = TestApp.create({ auth: false }) const responses = await Promise.all([ app.request('/v1/tokenlist?chainId=not-a-chain'), app.request('/v1/tokenlist?chainId=999999'), app.request('/v1/tokenlist?unknown=true'), ]) expect( await Promise.all( responses.map( async (response) => (await TestApp.json(response, Schema.ErrorResponse)).error.code, ), ), ).toStrictEqual(['chain_id_invalid', 'chain_id_unsupported', 'query_invalid']) }) }) describe('GET /tokenlist', () => { test('maps cold snapshot failures to the upstream error contract', async () => { const causes: Error[] = [] const db = Db.postgres({ connectionString: 'postgresql://postgres:postgres@127.0.0.1:1/none', }) const entries: Log.Entry[] = [] try { const app = TestApp.create({ auth: false, db, logger: (entry, cause) => { entries.push(entry) if (cause) causes.push(cause) }, verifiedTokens: { refreshMs: 0 }, }) const response = await app.request('/v1/tokenlist') expect(response.status).toBe(502) expect(await TestApp.json(response, Schema.ErrorResponse)).toMatchObject({ error: { code: 'upstream_error' }, }) expect(response.headers.get('cache-control')).toBe('no-store') expect(entries).toMatchObject([ { level: 'error', provider: { failure: 'query', id: 'verified_tokens', operation: 'snapshot' }, status: 502, }, ]) expect(causes).toHaveLength(1) expect(causes[0]?.message).toBeTruthy() } finally { await db.close() } }) test('serves verified tokens in the Uniswap token-list format', async () => { const response = await (await seeded()).v1.tokenlist.$get({ query: {} }, as(reader)) expect(response.status).toBe(200) const body = await TestApp.json(response, Tokenlist.schema.getTokenList.Response) expect(body.name).toMatch(/^Tempo/) expect(body.version).toEqual({ major: 1, minor: 0, patch: 2 }) // Token entries carry the standard fields, scoped to the requested chain, // with no `extensions`; a curated `logoUri` is preserved when present. expect(body.tokens).toEqual([ { address: '0x20c0000000000000000000000000000000001001', chainId, decimals: 6, logoURI: 'https://example.com/susd.svg', name: 'Seed USD', symbol: 'sUSD', }, { address: '0x20c0000000000000000000000000000000001002', chainId, decimals: 2, name: 'Seed EUR', symbol: 'sEUR', }, ]) expect(body.tokens[0]).not.toHaveProperty('extensions') }) test('serves an empty list for a chain with no verified tokens', async () => { const response = await ( await seeded() ).v1.tokenlist.$get({ query: { chainId: '4217' } }, as(reader)) expect(response.status).toBe(200) const body = await TestApp.json(response, Tokenlist.schema.getTokenList.Response) expect(body.name).toBe('Tempo Mainnet') expect(body.tokens).toEqual([]) expect(body.version.patch).toBe(0) }) test('refreshes cached reads after a snapshot publish', async () => { const db = TestApp.database() await VerifiedTokens.replace(db, chainId, seedList) const client = TestApp.client({ auth: { keys: [reader] }, db }) await client.v1.tokenlist.$get({ query: {} }, as(reader)) await VerifiedTokens.replace(db, chainId, seedList.slice(1)) const response = await client.v1.tokenlist.$get({ query: {} }, as(reader)) const body = await TestApp.json(response, Tokenlist.schema.getTokenList.Response) expect(body.tokens.map((token) => token.address)).toEqual( seedList.slice(1).map((token) => token.address), ) }) })