import { Address, Hash } from 'ox' import { Schema } from 'tapimo/server' import * as TestApp from '../../../../test/App.js' import * as Runtime from '../../../../test/runtime.js' import * as Coingecko from './coingecko.js' type JsonSchema = { $ref?: string description?: string enum?: readonly string[] items?: JsonSchema properties?: Record required?: readonly string[] } type OpenApiResponse = { $ref?: string content?: Record } type OpenApiOperation = { operationId?: string responses?: Record } type OpenApiDocument = { components: { schemas: Record } paths: Record } // The adapter addresses the chain positionally, mirroring the legacy `/gecko` // contract GeckoTerminal consumes. const chainId = String(Runtime.get().chainId) /** pathUSD — known to exist and have supply on Moderato. */ const pathUsd = '0x20c0000000000000000000000000000000000000' function component(spec: OpenApiDocument, name: string): JsonSchema { const schema = spec.components.schemas[name] if (!schema) throw new Error(`Missing OpenAPI component ${name}`) return schema } function resolveSchema(spec: OpenApiDocument, schema: JsonSchema | undefined): JsonSchema { if (!schema) throw new Error('Missing OpenAPI schema') if (!schema.$ref) return schema const name = schema.$ref.split('/').at(-1) if (!name) throw new Error(`Invalid OpenAPI component ref ${schema.$ref}`) return component(spec, name) } describe('OpenAPI', () => { test('publishes generator-ready CoinGecko compatibility contracts', async () => { const app = TestApp.create({ auth: false }) const spec = (await (await app.request('/openapi.json')).json()) as OpenApiDocument const operations = { asset: spec.paths['/gecko/{chainId}/assets/{address}']?.get, events: spec.paths['/gecko/{chainId}/events']?.get, latestBlock: spec.paths['/gecko/{chainId}/latest-block']?.get, pair: spec.paths['/gecko/{chainId}/pairs/{pairId}']?.get, pairs: spec.paths['/gecko/{chainId}/pairs']?.get, } if (Object.values(operations).some((operation) => !operation)) throw new Error('Missing CoinGecko OpenAPI operation') const summarizeError = (response: OpenApiResponse | undefined) => { if (!response) return undefined if (response.$ref) return { $ref: response.$ref } const schema = response.content?.['application/json']?.schema return resolveSchema(spec, schema).properties?.['error']?.properties?.['code']?.enum } const summarize = (operation: OpenApiOperation | undefined) => { if (!operation) throw new Error('Missing CoinGecko OpenAPI operation') return { errors: Object.fromEntries( [400, 401, 402, 403, 404, 429, 500, 502, 504] .filter((status) => operation.responses?.[status]) .map((status) => [status, summarizeError(operation.responses?.[status])]), ), operationId: operation.operationId, response: operation.responses?.['200']?.content?.['application/json']?.schema, } } const components = { asset: component(spec, 'CoinGeckoAsset'), assetResponse: component(spec, 'CoinGeckoAssetResponse'), block: component(spec, 'CoinGeckoBlock'), eventsResponse: component(spec, 'CoinGeckoEventsResponse'), latestBlockResponse: component(spec, 'CoinGeckoLatestBlockResponse'), pair: component(spec, 'CoinGeckoPair'), pairResponse: component(spec, 'CoinGeckoPairResponse'), pairsResponse: component(spec, 'CoinGeckoPairsResponse'), swapEvent: component(spec, 'CoinGeckoSwapEvent'), } const componentNames = Object.keys(spec.components.schemas) .filter((name) => name.startsWith('CoinGecko')) .sort() expect({ components: componentNames, models: { asset: components.assetResponse.properties?.['asset'], block: components.latestBlockResponse.properties?.['block'], eventBlock: components.swapEvent.properties?.['block'], events: components.eventsResponse.properties?.['events']?.items, pair: components.pairResponse.properties?.['pair'], pairs: components.pairsResponse.properties?.['pairs']?.items, }, operations: Object.fromEntries( Object.entries(operations).map(([name, operation]) => [name, summarize(operation)]), ), required: { asset: components.asset.required, block: components.block.required, pair: components.pair.required, swapEvent: components.swapEvent.required, }, undescribedComponents: componentNames.filter((name) => !component(spec, name).description), }).toMatchInlineSnapshot(` { "components": [ "CoinGeckoAddress", "CoinGeckoAsset", "CoinGeckoAssetResponse", "CoinGeckoBlock", "CoinGeckoEventsResponse", "CoinGeckoLatestBlockResponse", "CoinGeckoPair", "CoinGeckoPairResponse", "CoinGeckoPairToken", "CoinGeckoPairsResponse", "CoinGeckoSwapEvent", ], "models": { "asset": { "$ref": "#/components/schemas/CoinGeckoAsset", }, "block": { "$ref": "#/components/schemas/CoinGeckoBlock", }, "eventBlock": { "$ref": "#/components/schemas/CoinGeckoBlock", }, "events": { "$ref": "#/components/schemas/CoinGeckoSwapEvent", }, "pair": { "$ref": "#/components/schemas/CoinGeckoPair", }, "pairs": { "$ref": "#/components/schemas/CoinGeckoPair", }, }, "operations": { "asset": { "errors": { "400": [ "address_invalid", "api_key_malformed", "chain_id_invalid", "chain_id_unsupported", ], "401": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "402": { "$ref": "#/components/responses/PaymentRequired", }, "403": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "404": [ "not_found", ], "429": { "$ref": "#/components/responses/RateLimited", }, "500": { "$ref": "#/components/responses/InternalError", }, "502": [ "upstream_error", ], "504": { "$ref": "#/components/responses/RequestTimeout", }, }, "operationId": "coingeckoAsset", "response": { "$ref": "#/components/schemas/CoinGeckoAssetResponse", }, }, "events": { "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", ], "404": [ "not_found", ], "429": { "$ref": "#/components/responses/RateLimited", }, "500": { "$ref": "#/components/responses/InternalError", }, "502": [ "upstream_error", ], "504": { "$ref": "#/components/responses/RequestTimeout", }, }, "operationId": "coingeckoEvents", "response": { "$ref": "#/components/schemas/CoinGeckoEventsResponse", }, }, "latestBlock": { "errors": { "400": [ "api_key_malformed", "chain_id_invalid", "chain_id_unsupported", ], "401": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "402": { "$ref": "#/components/responses/PaymentRequired", }, "403": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "404": [ "not_found", ], "429": { "$ref": "#/components/responses/RateLimited", }, "500": { "$ref": "#/components/responses/InternalError", }, "502": [ "upstream_error", ], "504": { "$ref": "#/components/responses/RequestTimeout", }, }, "operationId": "coingeckoLatestBlock", "response": { "$ref": "#/components/schemas/CoinGeckoLatestBlockResponse", }, }, "pair": { "errors": { "400": [ "api_key_malformed", "chain_id_invalid", "chain_id_unsupported", "pair_id_invalid", ], "401": [ "api_key_invalid", "api_key_missing", "unauthorized", ], "402": { "$ref": "#/components/responses/PaymentRequired", }, "403": [ "api_key_forbidden", "api_key_ip_forbidden", "forbidden", ], "404": [ "not_found", ], "429": { "$ref": "#/components/responses/RateLimited", }, "500": { "$ref": "#/components/responses/InternalError", }, "502": [ "upstream_error", ], "504": { "$ref": "#/components/responses/RequestTimeout", }, }, "operationId": "coingeckoPair", "response": { "$ref": "#/components/schemas/CoinGeckoPairResponse", }, }, "pairs": { "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": "coingeckoPairs", "response": { "$ref": "#/components/schemas/CoinGeckoPairsResponse", }, }, }, "required": { "asset": [ "id", "name", "symbol", "decimals", "totalSupply", ], "block": [ "blockNumber", "blockTimestamp", ], "pair": [ "id", "dexKey", "asset0Id", "asset1Id", "token0", "token1", "reserve0", "reserve1", ], "swapEvent": [ "block", "eventType", "txnId", "txnIndex", "eventIndex", "maker", "pairId", "priceNative", "reserves", ], }, "undescribedComponents": [], } `) }) test('returns the existing route-specific validation errors', async () => { const app = TestApp.create({ auth: false }) const invalidChain = await app.request('/gecko/not-a-chain/latest-block') const unsupportedChain = await app.request('/gecko/999999/latest-block') const invalidAssetChain = await app.request(`/gecko/not-a-chain/assets/${pathUsd}`) const invalidAsset = await app.request(`/gecko/${chainId}/assets/not-an-address`) const invalidPairChain = await app.request(`/gecko/not-a-chain/pairs/0x${'11'.repeat(32)}`) const invalidPair = await app.request(`/gecko/${chainId}/pairs/not-a-pair`) const invalidQuery = await app.request(`/gecko/${chainId}/pairs?limit=0`) expect( await Promise.all( [ invalidChain, unsupportedChain, invalidAssetChain, invalidAsset, invalidPairChain, invalidPair, invalidQuery, ].map(async (response) => (await TestApp.json(response, Schema.ErrorResponse)).error.code), ), ).toStrictEqual([ 'chain_id_invalid', 'chain_id_unsupported', 'chain_id_invalid', 'address_invalid', 'chain_id_invalid', 'pair_id_invalid', 'query_invalid', ]) }) }) describe('GET /gecko/:chainId/latest-block', () => { test('returns the latest indexed block', async () => { const client = TestApp.client() const response = await client.gecko[':chainId']['latest-block'].$get( { param: { chainId } }, TestApp.auth, ) expect(response.status).toBe(200) // The live chain tip is never cached and carries no Cache-Control header. expect(response.headers.get('Cache-Control')).toBeNull() const body = await TestApp.json(response, Coingecko.schema.LatestBlockResponse) expect(body.block.blockNumber).toBeGreaterThan(0) expect(body.block.blockTimestamp).toBeGreaterThan(0) }) test('rejects an unsupported chain id', async () => { const client = TestApp.client() const response = await client.gecko[':chainId']['latest-block'].$get( { param: { chainId: '999' } }, TestApp.auth, ) expect(response.status).toBe(400) }) }) describe('GET /gecko/:chainId/assets/:address', () => { test('returns asset metadata in GeckoTerminal shape', async () => { const client = TestApp.client() const response = await client.gecko[':chainId'].assets[':address'].$get( { param: { chainId, address: pathUsd } }, TestApp.auth, ) expect(response.status).toBe(200) const body = await TestApp.json(response, Coingecko.schema.AssetResponse) expect(body.asset.id).toBe(Address.checksum(pathUsd)) expect(body.asset.decimals).toBeGreaterThanOrEqual(0) expect(typeof body.asset.symbol).toBe('string') expect(body.asset.totalSupply).toMatch(/^\d+(\.\d+)?$/) }) test('rejects a malformed address', async () => { const client = TestApp.client() const response = await client.gecko[':chainId'].assets[':address'].$get( { param: { chainId, address: 'not-an-address' } }, TestApp.auth, ) expect(response.status).toBe(400) }) test('returns 404 for an address that is not a TIP-20 token', async () => { const client = TestApp.client() const response = await client.gecko[':chainId'].assets[':address'].$get( // A syntactically valid address that is not a registered TIP-20: the // metadata read reverts, which must surface as a `404`, not a `502`. { param: { chainId, address: '0xc97613eb1c39b0b57267739b5400ab08f8c4c285' } }, TestApp.auth, ) const body = await TestApp.json(response, Schema.ErrorResponse) expect(response.status).toBe(404) expect(body.error.code).toBe('not_found') }) }) describe('GET /gecko/:chainId/pairs', () => { test('lists trading pairs with token metadata and reserves', async () => { const client = TestApp.client() const response = await client.gecko[':chainId'].pairs.$get({ param: { chainId } }, TestApp.auth) expect(response.status).toBe(200) expect(response.headers.get('server-timing')?.includes('coingecko_metadata;dur=')).toBe(true) const body = await TestApp.json(response, Coingecko.schema.PairsResponse) expect(body.pairs.length).toBeGreaterThan(0) const pair = body.pairs[0]! expect(pair.dexKey).toBe('tempo-stablecoin-dex') expect(Hash.validate(pair.id)).toBe(true) expect(pair.asset0Id).toBe(pair.token0.address) expect(pair.asset1Id).toBe(pair.token1.address) expect(pair.reserve0).toMatch(/^\d+(\.\d+)?$/) expect(pair.reserve1).toMatch(/^\d+(\.\d+)?$/) }) test('reports per-book reserves, not the DEX-wide token balance', async () => { // Regression: reserves were previously the DEX settlement account's // `balanceOf`, so every book sharing a quote token (e.g. pathUSD) reported // the same `reserve1`. Per-book resting-order liquidity makes them distinct. const client = TestApp.client() const response = await client.gecko[':chainId'].pairs.$get( { param: { chainId }, query: { limit: '50' } }, TestApp.auth, ) expect(response.status).toBe(200) const body = await TestApp.json(response, Coingecko.schema.PairsResponse) // Group reserves by quote token and assert the largest shared-quote group // is not collapsed to a single value (the old balance-based bug). const byQuote = new Map() for (const pair of body.pairs) { const group = byQuote.get(pair.asset1Id) ?? [] group.push(pair.reserve1) byQuote.set(pair.asset1Id, group) } const varied = [...byQuote.values()].find( (reserves) => reserves.length > 1 && new Set(reserves).size > 1, ) if (!varied) return expect(new Set(varied).size).toBeGreaterThan(1) }) test('honors the `limit` search param', async () => { const client = TestApp.client() const response = await client.gecko[':chainId'].pairs.$get( { param: { chainId }, query: { limit: '1' } }, TestApp.auth, ) expect(response.status).toBe(200) const body = await TestApp.json(response, Coingecko.schema.PairsResponse) expect(body.pairs.length).toBeLessThanOrEqual(1) }) test('rejects a `limit` above the cap', async () => { const client = TestApp.client() const response = await client.gecko[':chainId'].pairs.$get( { param: { chainId }, query: { limit: '99999' } }, TestApp.auth, ) expect(response.status).toBe(400) }) }) describe('GET /gecko/:chainId/pairs/:pairId', () => { test('returns a single pair by its order-book key', async () => { const client = TestApp.client() const list = await client.gecko[':chainId'].pairs.$get({ param: { chainId } }, TestApp.auth) const pairs = await TestApp.json(list, Coingecko.schema.PairsResponse) const target = pairs.pairs[0] if (!target) return const response = await client.gecko[':chainId'].pairs[':pairId'].$get( { param: { chainId, pairId: target.id } }, TestApp.auth, ) expect(response.status).toBe(200) const body = await TestApp.json(response, Coingecko.schema.PairResponse) expect(body.pair.id).toBe(target.id) }) test('rejects a malformed pair id', async () => { const client = TestApp.client() const response = await client.gecko[':chainId'].pairs[':pairId'].$get( { param: { chainId, pairId: '0xnothex' } }, TestApp.auth, ) expect(response.status).toBe(400) }) }) describe('GET /gecko/:chainId/events', () => { test('returns swap events for a recent block range', async () => { const client = TestApp.client() const tip = await client.gecko[':chainId']['latest-block'].$get( { param: { chainId } }, TestApp.auth, ) const { block } = await TestApp.json(tip, Coingecko.schema.LatestBlockResponse) const toBlock = block.blockNumber const fromBlock = Math.max(0, toBlock - 400) const response = await client.gecko[':chainId'].events.$get( { param: { chainId }, query: { fromBlock: String(fromBlock), toBlock: String(toBlock) } }, TestApp.auth, ) expect(response.status).toBe(200) const body = await TestApp.json(response, Coingecko.schema.EventsResponse) // The fan-out segments only emit once the window contains fills. if (body.events.length > 0) { const timing = response.headers.get('server-timing') expect(timing?.includes('coingecko_pair_index;dur=')).toBe(true) expect(timing?.includes('coingecko_metadata;dur=')).toBe(true) } for (const event of body.events) { expect(event.eventType).toBe('swap') expect(Hash.validate(event.pairId)).toBe(true) expect(event.priceNative).toMatch(/^\d+(\.\d+)?$/) // Exactly one base side and one quote side per swap. expect(Boolean(event.asset0In) !== Boolean(event.asset0Out)).toBe(true) expect(Boolean(event.asset1In) !== Boolean(event.asset1Out)).toBe(true) } }) test('defaults the block range when omitted', async () => { const client = TestApp.client() // Omit `toBlock` (defaults to the latest indexed block) and pass an explicit // `fromBlock` close to the tip so the resolved window stays small and fast. const tip = await client.gecko[':chainId']['latest-block'].$get( { param: { chainId } }, TestApp.auth, ) const { block } = await TestApp.json(tip, Coingecko.schema.LatestBlockResponse) const fromBlock = Math.max(0, block.blockNumber - 100) const response = await client.gecko[':chainId'].events.$get( { param: { chainId }, query: { fromBlock: String(fromBlock) } }, TestApp.auth, ) expect(response.status).toBe(200) await TestApp.json(response, Coingecko.schema.EventsResponse) }) test('rejects a non-integer block range', async () => { const client = TestApp.client() const response = await client.gecko[':chainId'].events.$get( { param: { chainId }, query: { fromBlock: '1.5', toBlock: '2' } }, TestApp.auth, ) expect(response.status).toBe(400) }) test('rejects an oversized block range', async () => { const client = TestApp.client() const response = await client.gecko[':chainId'].events.$get( { param: { chainId }, query: { fromBlock: '0', toBlock: '1000000' } }, TestApp.auth, ) expect(response.status).toBe(400) }) })