diff --git a/.env.example b/.env.example index 2724c9a..18036b6 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,10 @@ UPSTREAM_TTL_MS=30000 # Chain tip polling (feeds the /ws blockHeight broadcast) CHAIN_TIP_POLL_MS=10000 +# CoinGecko prices (GET /prices — full bridge asset set, server-cached) +CG_API_KEY= +PRICES_TTL_MS=600000 + # IPFS cache IPFS_GATEWAYS=https://gateway.pinata.cloud/ipfs,https://4everland.io/ipfs,https://ipfs.io/ipfs IPFS_TIMEOUT_MS=12000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a30fb13..c622d30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version-file: .nvmrc cache: npm - name: Install dependencies diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..10fef25 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.18 diff --git a/README.md b/README.md index 5c87108..3f53103 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # mojito-api +[![CI](https://github.com/mintlayer/mojito-api/actions/workflows/ci.yml/badge.svg)](https://github.com/mintlayer/mojito-api/actions/workflows/ci.yml) + Mojito API gateway: the Mintlayer **batch/aggregation service** behind `mojito-api.mintlayer.org` and `api.mintini.app`, plus a hardened **IPFS content cache**. diff --git a/src/app.module.ts b/src/app.module.ts index 2aea6e5..08e17c9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { PriceService } from './batch/price.service'; import { UpstreamModule } from './batch/upstream.module'; import { ChainModule } from './chain/chain.module'; import { IpfsModule } from './ipfs/ipfs.module'; +import { PricesModule } from './prices/prices.module'; import configuration, { configValidationSchema } from './config/configuration'; @Module({ @@ -24,6 +25,7 @@ import configuration, { configValidationSchema } from './config/configuration'; UpstreamModule, ChainModule, IpfsModule, + PricesModule, ], controllers: [BatchController, MintiniController], providers: [PriceService, { provide: APP_GUARD, useClass: ThrottlerGuard }], diff --git a/src/config/configuration.ts b/src/config/configuration.ts index d87033b..0675262 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -37,6 +37,10 @@ export const configValidationSchema = Joi.object({ .default(5 * 1024 * 1024), IPFS_MEMORY_CACHE_MAX_ENTRIES: Joi.number().min(0).default(5000), IPFS_NEGATIVE_TTL_MS: Joi.number().min(0).default(60000), + + // Optional CoinGecko demo/pro key (x-cg-demo-api-key header) + CG_API_KEY: Joi.string().allow('').default(''), + PRICES_TTL_MS: Joi.number().min(60_000).default(600_000), }); export default () => ({ @@ -50,6 +54,12 @@ export default () => ({ }, upstreamTtlMs: parseInt(process.env.UPSTREAM_TTL_MS ?? '30000', 10), chainTipPollMs: parseInt(process.env.CHAIN_TIP_POLL_MS ?? '10000', 10), + cg: { + apiKey: process.env.CG_API_KEY ?? '', + }, + prices: { + ttlMs: parseInt(process.env.PRICES_TTL_MS ?? '600000', 10), + }, ipfs: { gateways: (process.env.IPFS_GATEWAYS ?? '').split(',').filter(Boolean), timeoutMs: parseInt(process.env.IPFS_TIMEOUT_MS ?? '12000', 10), diff --git a/src/prices/prices.controller.spec.ts b/src/prices/prices.controller.spec.ts new file mode 100644 index 0000000..7a63fe9 --- /dev/null +++ b/src/prices/prices.controller.spec.ts @@ -0,0 +1,138 @@ +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { PricesController } from './prices.controller'; +import { PricesService } from './prices.service'; +import { BRIDGE_TICKERS, PRICE_ID_BY_TICKER } from './ticker-map'; + +describe('PricesController', () => { + let app: INestApplication; + + const getPricesMock = jest.fn(); + const coveredTickerCountMock = jest.fn(); + const isStaleMock = jest.fn(); + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [PricesController], + providers: [ + { + provide: PricesService, + useValue: { + getPrices: getPricesMock, + coveredTickerCount: coveredTickerCountMock, + isStale: isStaleMock, + }, + }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + getPricesMock.mockReset(); + coveredTickerCountMock.mockReset(); + isStaleMock.mockReset(); + }); + + describe('GET /prices', () => { + it('passes the full map through when no tickers filter is given', async () => { + const prices = { ml: 0.05, wbtc: 60_000, waaplx: 228.4 }; + getPricesMock.mockResolvedValue(prices); + + const response = await request(app.getHttpServer()) + .get('/prices') + .expect(200); + + expect(response.body).toEqual(prices); + expect(getPricesMock).toHaveBeenCalledTimes(1); + expect(getPricesMock).toHaveBeenCalledWith(undefined); + }); + + it('splits, trims and drops empty parts of ?tickers before delegating to the service', async () => { + getPricesMock.mockResolvedValue({ wbtc: 60_000, ml: 0.05 }); + + const response = await request(app.getHttpServer()) + .get(`/prices?tickers=${encodeURIComponent(' WBTC , ml , , ')}`) + .expect(200); + + // Case is left intact here — lowercasing is the service's job. + expect(getPricesMock).toHaveBeenCalledWith(['WBTC', 'ml']); + expect(response.body).toEqual({ wbtc: 60_000, ml: 0.05 }); + }); + + it('delegates an empty filter list when ?tickers is made only of blanks', async () => { + getPricesMock.mockResolvedValue({}); + + await request(app.getHttpServer()) + .get(`/prices?tickers=${encodeURIComponent(' , , ')}`) + .expect(200); + + expect(getPricesMock).toHaveBeenCalledWith([]); + }); + + it('caps the ?tickers filter at the covered ticker count', async () => { + getPricesMock.mockResolvedValue({ ml: 0.05 }); + + const query = Array.from({ length: 50 }, (_, i) => ` ticker${i} `).join( + ',', + ); + await request(app.getHttpServer()) + .get(`/prices?tickers=${encodeURIComponent(query)}`) + .expect(200); + + // Query noise beyond the covered set is dropped before the service sees it. + expect(getPricesMock).toHaveBeenCalledTimes(1); + expect(getPricesMock).toHaveBeenCalledWith( + Array.from({ length: BRIDGE_TICKERS.length }, (_, i) => `ticker${i}`), + ); + }); + + it('is CORS-open for the browser bridge', async () => { + getPricesMock.mockResolvedValue({ ml: 0.05 }); + + const response = await request(app.getHttpServer()) + .get('/prices') + .expect(200); + + expect(response.headers['access-control-allow-origin']).toBe('*'); + }); + }); + + describe('GET /prices/coverage', () => { + it('reports the covered count, staleness and the full ticker map', async () => { + coveredTickerCountMock.mockReturnValue(36); + isStaleMock.mockReturnValue(false); + + const response = await request(app.getHttpServer()) + .get('/prices/coverage') + .expect(200); + + expect(response.body).toEqual({ + covered: 36, + pricesStale: false, + map: PRICE_ID_BY_TICKER, + }); + expect(coveredTickerCountMock).toHaveBeenCalledTimes(1); + expect(isStaleMock).toHaveBeenCalledTimes(1); + }); + + it('is CORS-open and forwards the staleness flag', async () => { + coveredTickerCountMock.mockReturnValue(36); + isStaleMock.mockReturnValue(true); + + const response = await request(app.getHttpServer()) + .get('/prices/coverage') + .expect(200); + + expect(response.headers['access-control-allow-origin']).toBe('*'); + expect(response.body.pricesStale).toBe(true); + }); + }); +}); diff --git a/src/prices/prices.controller.ts b/src/prices/prices.controller.ts new file mode 100644 index 0000000..5437f99 --- /dev/null +++ b/src/prices/prices.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Header, Query } from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { PricesService } from './prices.service'; +import { BRIDGE_TICKERS, PRICE_ID_BY_TICKER } from './ticker-map'; + +/** + * USD prices for the full bridge asset set (crypto + 20 xStocks). + * CORS-open: consumed by bridge.mintlayer.org in the browser. + */ +@ApiTags('Prices') +@Controller('prices') +export class PricesController { + constructor(private readonly prices: PricesService) {} + + @Get() + @ApiOperation({ + summary: + 'USD prices for bridge assets (CoinGecko, server-cached). Filter with ?tickers=wbtc,ml', + }) + @ApiQuery({ + name: 'tickers', + required: false, + description: 'Comma-separated bridge tickers (default: all)', + example: 'wbtc,ml,waaplx', + }) + @Header('Access-Control-Allow-Origin', '*') + async getPrices(@Query('tickers') tickersStr?: string) { + // The filter can only ever select from the fixed covered set — anything + // beyond BRIDGE_TICKERS.length entries is query noise, so it is capped. + const tickers = tickersStr + ? tickersStr + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + .slice(0, BRIDGE_TICKERS.length) + : undefined; + return this.prices.getPrices(tickers); + } + + @Get('coverage') + @ApiOperation({ + summary: 'Ticker → CoinGecko id map for every bridgeable asset', + }) + @Header('Access-Control-Allow-Origin', '*') + coverage() { + return { + covered: this.prices.coveredTickerCount(), + pricesStale: this.prices.isStale(), + map: PRICE_ID_BY_TICKER, + }; + } +} diff --git a/src/prices/prices.module.ts b/src/prices/prices.module.ts new file mode 100644 index 0000000..ded9096 --- /dev/null +++ b/src/prices/prices.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PricesController } from './prices.controller'; +import { PricesService } from './prices.service'; + +@Module({ + controllers: [PricesController], + providers: [PricesService], +}) +export class PricesModule {} diff --git a/src/prices/prices.service.spec.ts b/src/prices/prices.service.spec.ts new file mode 100644 index 0000000..76b3ba3 --- /dev/null +++ b/src/prices/prices.service.spec.ts @@ -0,0 +1,398 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MIN_RETRY_MS, PricesService } from './prices.service'; +import { BRIDGE_TICKERS, PRICE_ID_BY_TICKER } from './ticker-map'; + +const TTL_MS = 600_000; + +interface CgPriceEntry { + usd?: number; +} +type CgPayload = Record; +interface CgResponse { + ok: boolean; + status?: number; + json: () => Promise; +} +interface FetchInit { + headers: Record; + signal?: unknown; +} + +const cgOk = (payload: CgPayload): CgResponse => ({ + ok: true, + json: () => Promise.resolve(payload), +}); + +const cgError = (status: number): CgResponse => ({ + ok: false, + status, + json: () => Promise.resolve({}), +}); + +/** Every bridge id answers with a distinct positive usd value (ml = 1, wbtc = 6, …). */ +const allPricesPayload = (): CgPayload => + Object.fromEntries( + BRIDGE_TICKERS.map((ticker, index) => [ + PRICE_ID_BY_TICKER[ticker], + { usd: index + 1 }, + ]), + ); + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +/** ConfigService stub per the ipfs spec convention: overrides win, else the default. */ +const makeService = (overrides: Record = {}) => + new PricesService({ + get: (key: string, defaultValue?: unknown) => + key in overrides ? overrides[key] : defaultValue, + } as unknown as ConfigService); + +const initOf = (call: unknown[]): FetchInit => call[1] as FetchInit; + +/** Decodes the `ids=` param of a recorded fetch call after asserting the batch shape. */ +const idsFromCall = (call: unknown[]): string[] => { + const url = call[0] as string; + expect( + url.startsWith('https://api.coingecko.com/api/v3/simple/price?ids='), + ).toBe(true); + const rawIds = url.match(/ids=([^&]+)/)![1]; + // commas are percent-encoded — 35 separators + 1 = 36 ids in one batch + expect((rawIds.match(/%2C/g) ?? []).length + 1).toBe(36); + const params = new URL(url).searchParams; + expect(params.get('vs_currencies')).toBe('usd'); + const ids = decodeURIComponent(rawIds).split(','); + expect(ids).toHaveLength(36); + expect(new Set(ids).size).toBe(36); + return ids; +}; + +describe('PricesService', () => { + const originalFetch = globalThis.fetch; + let fetchMock: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + fetchMock = jest.fn(); + globalThis.fetch = fetchMock; + jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => {}); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + describe('refresh — happy path', () => { + it('populates every bridge ticker from one batched fetch, dropping invalid entries', async () => { + const payload = allPricesPayload(); + payload['pepe'] = { usd: 0 }; // zero → unusable + payload['shiba-inu'] = { usd: -0.01 }; // negative → unusable + payload['aave'] = {}; // usd missing → unusable + delete payload['compound-governance-token']; // id absent from the response + + fetchMock.mockResolvedValue(cgOk(payload)); + const service = makeService(); + + const prices = await service.getPrices(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(Object.keys(prices)).toHaveLength(BRIDGE_TICKERS.length - 4); + for (const dropped of ['pepe', 'shib', 'aave', 'comp']) { + expect(prices[dropped]).toBeUndefined(); + } + expect(prices['ml']).toBe(1); + expect(prices['wbtc']).toBe(6); + expect(prices['waaplx']).toBe(17); + for (const value of Object.values(prices)) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThan(0); + } + }); + + it('batches all 36 CoinGecko ids into a single simple/price URL', async () => { + fetchMock.mockResolvedValue(cgOk(allPricesPayload())); + const service = makeService(); + + await service.getPrices(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const ids = idsFromCall(fetchMock.mock.calls[0]); + expect(ids).toContain('mintlayer'); + expect(ids).toContain('apple-xstock'); + expect(ids).toContain('wrapped-bitcoin'); + expect(initOf(fetchMock.mock.calls[0]).headers.accept).toBe( + 'application/json', + ); + expect(initOf(fetchMock.mock.calls[0]).signal).toBeDefined(); + }); + }); + + describe('getPrices — filtering', () => { + it('filters case-insensitively with trimming and omits unknown tickers', async () => { + fetchMock.mockResolvedValue(cgOk(allPricesPayload())); + const service = makeService(); + await service.getPrices(); + + const filtered = await service.getPrices([ + 'WBTC ', + ' ml ', + ' ', + 'nope', + '', + ]); + + expect(filtered).toEqual({ wbtc: 6, ml: 1 }); + }); + + it('treats an empty filter list like no filter and returns everything', async () => { + fetchMock.mockResolvedValue(cgOk(allPricesPayload())); + const service = makeService(); + + const all = await service.getPrices([]); + + expect(Object.keys(all)).toHaveLength(36); + }); + }); + + describe('getPrices — result isolation', () => { + it('returns a copy: mutating the result must not affect the cached map', async () => { + fetchMock.mockResolvedValue(cgOk(allPricesPayload())); + const service = makeService(); + const first = await service.getPrices(); + first['ml'] = 999_999_999; + delete first['wbtc']; + + const second = await service.getPrices(); + + expect(second['ml']).toBe(1); + expect(second['wbtc']).toBe(6); + expect(Object.keys(second)).toHaveLength(36); + }); + }); + + describe('getPrices — TTL freshness', () => { + it('re-fetches once the cached map is older than the TTL', async () => { + fetchMock.mockResolvedValue(cgOk(allPricesPayload())); + const service = makeService(); + await service.getPrices(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(TTL_MS); + expect(service.isStale()).toBe(false); // exactly TTL is still fresh (strict >) + jest.advanceTimersByTime(1); + expect(service.isStale()).toBe(true); + + const prices = await service.getPrices(['ml']); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(prices).toEqual({ ml: 1 }); + expect(service.isStale()).toBe(false); + }); + + it('does not refresh at all while the cache is fresh (TTL honored on the request path)', async () => { + fetchMock.mockResolvedValue(cgOk(allPricesPayload())); + const service = makeService(); + await service.getPrices(); // refresh #1 → cache fresh + + jest.advanceTimersByTime(TTL_MS / 2); // still well within the TTL + expect(service.isStale()).toBe(false); + + const first = service.getPrices(); // fresh → served from the cached map + const second = service.getPrices(); // no refresh, same cached map + const third = service.getPrices(['wbtc']); + const [r1, r2, r3] = await Promise.all([first, second, third]); + + expect(fetchMock).toHaveBeenCalledTimes(1); // TTL honored: zero CG fetches while fresh + expect(Object.keys(r1)).toHaveLength(36); + expect(Object.keys(r2)).toHaveLength(36); + expect(r3).toEqual({ wbtc: 6 }); + }); + + it('honors a custom prices.ttlMs from config', async () => { + fetchMock.mockResolvedValue(cgOk(allPricesPayload())); + const service = makeService({ 'prices.ttlMs': 5_000 }); + await service.getPrices(); + + jest.advanceTimersByTime(4_999); + expect(service.isStale()).toBe(false); + jest.advanceTimersByTime(2); + expect(service.isStale()).toBe(true); + }); + }); + + describe('stale-on-error', () => { + it('keeps the previous map when a refresh fails, then replaces it after recovery', async () => { + fetchMock.mockResolvedValueOnce(cgOk(allPricesPayload())); + const service = makeService(); + const initial = await service.getPrices(); + expect(initial['ml']).toBe(1); + + jest.advanceTimersByTime(TTL_MS + 1); + fetchMock.mockRejectedValueOnce(new Error('network down')); + const afterFailure = await service.getPrices(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(afterFailure).toEqual(initial); // stale map still served + expect(service.isStale()).toBe(true); // fetchedAt untouched by the failure + + const recovered = { + ...allPricesPayload(), + mintlayer: { usd: 0.123 }, + }; + jest.advanceTimersByTime(MIN_RETRY_MS + 1); // out of the failed-refresh backoff window + fetchMock.mockResolvedValueOnce(cgOk(recovered)); + const afterRecovery = await service.getPrices(); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(afterRecovery['ml']).toBe(0.123); + expect(afterRecovery['wbtc']).toBe(6); + expect(service.isStale()).toBe(false); + }); + + it('treats a CoinGecko error status as a failed refresh', async () => { + fetchMock.mockResolvedValueOnce(cgOk(allPricesPayload())); + const service = makeService(); + const initial = await service.getPrices(); + + jest.advanceTimersByTime(TTL_MS + 1); + fetchMock.mockResolvedValueOnce(cgError(500)); + + await expect(service.getPrices()).resolves.toEqual(initial); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(service.isStale()).toBe(true); + }); + + it('keeps the stale map when CoinGecko answers with nothing usable', async () => { + fetchMock.mockResolvedValueOnce(cgOk(allPricesPayload())); + const service = makeService(); + const initial = await service.getPrices(); + + jest.advanceTimersByTime(TTL_MS + 1); + fetchMock.mockResolvedValueOnce(cgOk({})); // internal throw, caught + const result = await service.getPrices(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result).toEqual(initial); + expect(service.isStale()).toBe(true); + }); + + it('resolves empty instead of rejecting when the very first fetch fails', async () => { + fetchMock.mockRejectedValue(new Error('boom')); + const service = makeService(); + + await expect(service.getPrices()).resolves.toEqual({}); + }); + + it('resolves empty instead of rejecting when the first response has nothing usable', async () => { + fetchMock.mockResolvedValue(cgOk({ mintlayer: { usd: 0 } })); + const service = makeService(); + + await expect(service.getPrices()).resolves.toEqual({}); + }); + }); + + describe('failed-refresh backoff', () => { + it('throttles request-path retries during an upstream outage', async () => { + fetchMock.mockResolvedValueOnce(cgOk(allPricesPayload())); + const service = makeService(); + const initial = await service.getPrices(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(TTL_MS + 1); // cache goes stale + fetchMock.mockRejectedValueOnce(new Error('network down')); + const afterFailure = await service.getPrices(); // attempt #2 fails, backoff starts + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(afterFailure).toEqual(initial); // stale map still served + + const insideWindow = await service.getPrices(); // inside the retry window + expect(fetchMock).toHaveBeenCalledTimes(2); // throttled: no new CG call + expect(insideWindow).toEqual(initial); // previous map, no in-flight wait + + jest.advanceTimersByTime(MIN_RETRY_MS + 1); + fetchMock.mockRejectedValueOnce(new Error('still down')); + const afterBackoff = await service.getPrices(); // attempt #3 allowed + + expect(fetchMock).toHaveBeenCalledTimes(3); // one CG call per retry window + expect(afterBackoff).toEqual(initial); + }); + }); + + describe('CoinGecko auth header', () => { + it('sends x-cg-demo-api-key only when cg.apiKey is configured', async () => { + fetchMock.mockResolvedValue(cgOk(allPricesPayload())); + + const keyed = makeService({ 'cg.apiKey': 'CG-abc123' }); + await keyed.getPrices(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(initOf(fetchMock.mock.calls[0]).headers).toEqual({ + accept: 'application/json', + 'x-cg-demo-api-key': 'CG-abc123', + }); + + const anonymous = makeService(); + await anonymous.getPrices(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(initOf(fetchMock.mock.calls[1]).headers).toEqual({ + accept: 'application/json', + }); + }); + }); + + describe('concurrency', () => { + it('dedupes concurrent getPrices calls into a single in-flight refresh', async () => { + const gate = deferred(); + fetchMock.mockImplementation(() => gate.promise); + const service = makeService(); + + const first = service.getPrices(); + const second = service.getPrices(['wbtc', 'ml']); + const third = service.getPrices(); + gate.resolve(cgOk(allPricesPayload())); + const [r1, r2, r3] = await Promise.all([first, second, third]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(Object.keys(r1)).toHaveLength(36); + expect(r2).toEqual({ wbtc: 6, ml: 1 }); + expect(Object.keys(r3)).toHaveLength(36); + }); + }); + + describe('lifecycle', () => { + it('reports the covered ticker count without any network activity', () => { + const service = makeService(); + + expect(service.coveredTickerCount()).toBe(36); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refreshes on the TTL interval and stops after onModuleDestroy', async () => { + fetchMock.mockResolvedValue(cgOk(allPricesPayload())); + const service = makeService(); + service.onModuleInit(); + await service.getPrices(); // rides the boot refresh + expect(fetchMock).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(TTL_MS); // interval tick → background refresh + await service.getPrices(); + expect(fetchMock).toHaveBeenCalledTimes(2); + + service.onModuleDestroy(); + const count = fetchMock.mock.calls.length; + jest.advanceTimersByTime(TTL_MS * 10); + expect(fetchMock.mock.calls.length).toBe(count); // timer cleared, no leak + expect(() => service.onModuleDestroy()).not.toThrow(); // idempotent + }); + }); +}); diff --git a/src/prices/prices.service.ts b/src/prices/prices.service.ts new file mode 100644 index 0000000..469a0f2 --- /dev/null +++ b/src/prices/prices.service.ts @@ -0,0 +1,140 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { BRIDGE_TICKERS, PRICE_ID_BY_TICKER } from './ticker-map'; + +const CG_SIMPLE_PRICE_URL = 'https://api.coingecko.com/api/v3/simple/price'; +const DEFAULT_TTL_MS = 10 * 60_000; // one batched CG call per TTL, max +// Backoff for FAILED refreshes: fetchedAt only moves on success, so without +// this floor an upstream outage would turn every request into a new CG call +// (each awaiting up to the 15s timeout). With it, request-path retries are +// capped at one per MIN_RETRY_MS; the map keeps being served stale. +export const MIN_RETRY_MS = 30_000; + +/** + * Server-side USD prices for the full bridge asset set. + * + * Replaces the frontend's per-visitor CoinGecko calls (rate-limited for + * everyone behind one demo key) with a single cached batch per TTL, and + * adds the 20 xStocks tickers the frontend map never covered. + * + * Stale-on-error: a failed refresh keeps serving the previous map — + * prices are display-only, and an outage should hide the $ line via + * `stale`, not blank every ticker. + */ +@Injectable() +export class PricesService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PricesService.name); + private readonly ttlMs: number; + private prices: Record = {}; + private fetchedAt = 0; + private lastAttemptAt = 0; + private inFlight?: Promise; + private timer?: NodeJS.Timeout; + + constructor(private readonly config: ConfigService) { + this.ttlMs = this.config.get('prices.ttlMs', DEFAULT_TTL_MS); + } + + onModuleInit() { + void this.refresh(); + // opportunistic refresh on an interval; requests only trigger on demand + this.timer = setInterval(() => void this.refresh(), this.ttlMs); + } + + onModuleDestroy() { + if (this.timer) clearInterval(this.timer); + } + + isStale(): boolean { + return Date.now() - this.fetchedAt > this.ttlMs; + } + + /** + * USD prices keyed by bridge ticker. `tickers` filters the set + * (case-insensitive; unknown tickers are absent). Empty result only + * before the first successful fetch. + */ + async getPrices(tickers?: string[]): Promise> { + // refresh() dedupes concurrent callers; staleness + the failed-refresh + // backoff gate a new fetch, so sequential traffic stays at one CG call + // per TTL when healthy and one per MIN_RETRY_MS during an outage. + if (this.isStale() && Date.now() - this.lastAttemptAt > MIN_RETRY_MS) { + void this.refresh(); + } + if (this.inFlight) await this.inFlight.catch(() => {}); + + if (!tickers || tickers.length === 0) { + return { ...this.prices }; + } + const wanted = new Set( + tickers.map((t) => t.toLowerCase().trim()).filter(Boolean), + ); + const out: Record = {}; + for (const ticker of wanted) { + const price = this.prices[ticker]; + if (price != null) out[ticker] = price; + } + return out; + } + + coveredTickerCount(): number { + return BRIDGE_TICKERS.length; + } + + private async refresh(): Promise { + if (this.inFlight) return this.inFlight; + // Record the attempt up front: also covers the interval-triggered and + // boot refreshes, so the request path can never hot-loop a failing + // upstream right after one of those failed. + this.lastAttemptAt = Date.now(); + this.inFlight = this.doRefresh().finally(() => { + this.inFlight = undefined; + }); + return this.inFlight; + } + + private async doRefresh(): Promise { + const ids = Object.values(PRICE_ID_BY_TICKER); + const apiKey = this.config.get('cg.apiKey', ''); + const headers: Record = { accept: 'application/json' }; + if (apiKey) headers['x-cg-demo-api-key'] = String(apiKey); + + try { + const response = await fetch( + `${CG_SIMPLE_PRICE_URL}?ids=${encodeURIComponent(ids.join(','))}&vs_currencies=usd`, + { headers, signal: AbortSignal.timeout(15000) }, + ); + if (!response.ok) { + throw new Error(`CoinGecko HTTP ${response.status}`); + } + const byId: Record = await response.json(); + const next: Record = {}; + // Iterating the map's own entries keeps ticker/id pairing structural — + // no per-ticker lookup that could assert or silently diverge. + for (const [ticker, id] of Object.entries(PRICE_ID_BY_TICKER)) { + const usd = byId[id]?.usd; + if (typeof usd === 'number' && Number.isFinite(usd) && usd > 0) { + next[ticker] = usd; + } + } + if (Object.keys(next).length === 0) { + throw new Error('CoinGecko returned no usable prices'); + } + this.prices = next; + this.fetchedAt = Date.now(); + this.logger.debug( + `refreshed ${Object.keys(next).length}/${BRIDGE_TICKERS.length} bridge prices`, + ); + } catch (error) { + // Stale-on-error: keep the previous map; next tick retries. + this.logger.warn( + `price refresh failed (serving ${Object.keys(this.prices).length} stale): ${(error as Error).message}`, + ); + } + } +} diff --git a/src/prices/ticker-map.spec.ts b/src/prices/ticker-map.spec.ts new file mode 100644 index 0000000..8b46228 --- /dev/null +++ b/src/prices/ticker-map.spec.ts @@ -0,0 +1,57 @@ +import { + BRIDGE_TICKERS, + PRICE_ID_BY_TICKER, + tickerToPriceId, +} from './ticker-map'; + +describe('ticker-map', () => { + it('covers exactly 36 bridge tickers', () => { + expect(BRIDGE_TICKERS).toHaveLength(36); + expect(Object.keys(PRICE_ID_BY_TICKER)).toHaveLength(36); + expect(BRIDGE_TICKERS).toEqual(Object.keys(PRICE_ID_BY_TICKER)); + }); + + it('maps every ticker to a non-empty lowercase kebab-case CoinGecko id', () => { + for (const [ticker, id] of Object.entries(PRICE_ID_BY_TICKER)) { + expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); + expect(ticker).toBe(ticker.toLowerCase()); + } + }); + + it('splits into 16 crypto entries and 20 xStock entries', () => { + const xstocks = Object.entries(PRICE_ID_BY_TICKER).filter(([, id]) => + id.endsWith('-xstock'), + ); + + expect(xstocks).toHaveLength(20); + expect(Object.keys(PRICE_ID_BY_TICKER).length - xstocks.length).toBe(16); + }); + + it('follows the -xstock id convention for the tokenized equities', () => { + expect(PRICE_ID_BY_TICKER['waaplx']).toBe('apple-xstock'); + expect(PRICE_ID_BY_TICKER['wtslax']).toBe('tesla-xstock'); + expect(PRICE_ID_BY_TICKER['wmcdx']).toBe('mcdonald-s-xstock'); + expect(PRICE_ID_BY_TICKER['wdisx']).toBe('the-walt-disney-xstock'); + expect(PRICE_ID_BY_TICKER['wiwmx']).toBe('russell-2000-xstock'); + expect(PRICE_ID_BY_TICKER['wjpmx']).toBe('jpmorgan-chase-xstock'); + }); + + it('covers the crypto/DeFi core of the bridge', () => { + expect(PRICE_ID_BY_TICKER['ml']).toBe('mintlayer'); + expect(PRICE_ID_BY_TICKER['wbtc']).toBe('wrapped-bitcoin'); + expect(PRICE_ID_BY_TICKER['usdc']).toBe('usd-coin'); + expect(PRICE_ID_BY_TICKER['link']).toBe('chainlink'); + }); + + it('resolves tickers case-insensitively', () => { + expect(tickerToPriceId('WBTC')).toBe('wrapped-bitcoin'); + expect(tickerToPriceId('wAaplX')).toBe('apple-xstock'); + expect(tickerToPriceId('ml')).toBe('mintlayer'); + }); + + it('returns undefined for unknown tickers', () => { + expect(tickerToPriceId('nope')).toBeUndefined(); + expect(tickerToPriceId('')).toBeUndefined(); + expect(tickerToPriceId('BTC')).toBeUndefined(); // bare BTC is not bridgeable + }); +}); diff --git a/src/prices/ticker-map.ts b/src/prices/ticker-map.ts new file mode 100644 index 0000000..903e64f --- /dev/null +++ b/src/prices/ticker-map.ts @@ -0,0 +1,54 @@ +/** + * USD price coverage for the FULL bridge asset set (bridge.mintlayer.org + * `agents-config`, mainnet flavor): 16 crypto/DeFi tickers + 20 xStocks + * tokenized equities. + * + * CoinGecko ids for the xStocks family follow the `-xstock` + * convention (e.g. `waaplx` wraps AAPLX → `apple-xstock`). Verified + * against the live CG simple/price + search endpoints. + */ +export const PRICE_ID_BY_TICKER: Record = { + // crypto / DeFi + ml: 'mintlayer', + usdc: 'usd-coin', + usdt: 'tether', + dai: 'dai', + weth: 'weth', + wbtc: 'wrapped-bitcoin', + wsteth: 'wrapped-steth', + pepe: 'pepe', + shib: 'shiba-inu', + aave: 'aave', + comp: 'compound-governance-token', + crv: 'curve-dao-token', + ldo: 'lido-dao', + link: 'chainlink', + ondo: 'ondo-finance', + uni: 'uniswap', + // xStocks (tokenized equities/ETFs) + waaplx: 'apple-xstock', + wamdx: 'amd-xstock', + wamznx: 'amazon-xstock', + wavgox: 'broadcom-xstock', + wdisx: 'the-walt-disney-xstock', + wgldx: 'gold-xstock', + wgooglx: 'alphabet-xstock', + wiwmx: 'russell-2000-xstock', + wjpmx: 'jpmorgan-chase-xstock', + wkox: 'coca-cola-xstock', + wllyx: 'eli-lilly-xstock', + wmcdx: 'mcdonald-s-xstock', + wmetax: 'meta-xstock', + wmsftx: 'microsoft-xstock', + wnflxx: 'netflix-xstock', + wnvdax: 'nvidia-xstock', + wqqqx: 'nasdaq-xstock', + wspyx: 'sp500-xstock', + wtslax: 'tesla-xstock', + wxomx: 'exxon-mobil-xstock', +}; + +export const BRIDGE_TICKERS = Object.keys(PRICE_ID_BY_TICKER); + +export const tickerToPriceId = (ticker: string): string | undefined => + PRICE_ID_BY_TICKER[ticker.toLowerCase()];