import { Address, Hash } from 'ox' import { Schema } from 'tapimo' import * as TestApp from '../../../../test/App.js' import * as Runtime from '../../../../test/runtime.js' import * as Coingecko from './coingecko.js' // 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' 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) }) })