From 1d316f51bd5405dbcfcae1ef4162742da8767c99 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 14 Sep 2026 15:39:42 +0400 Subject: [PATCH 1/4] ci: pin Node 20 via .nvmrc, add CI badge --- .nvmrc | 1 + README.md | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 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**. From 2cb8995708a62813aa2e7ba5b1b9cf959e458480 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Wed, 16 Sep 2026 00:09:18 +0400 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20/prices=20=E2=80=94=20USD=20prices?= =?UTF-8?q?=20for=20the=20full=20bridge=20asset=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 36 tickers (16 crypto + 20 xStocks) mapped to CoinGecko ids, single server-side batched fetch per TTL (replaces per-visitor CG calls in the bridge frontend; covers the xStocks family the frontend map never had). CORS-open; stale-on-error keeps serving the last good map. CG_API_KEY env optional. --- .env.example | 4 + src/app.module.ts | 2 + src/config/configuration.ts | 10 + src/prices/prices.controller.spec.ts | 121 +++++++++ src/prices/prices.controller.ts | 49 ++++ src/prices/prices.module.ts | 9 + src/prices/prices.service.spec.ts | 374 +++++++++++++++++++++++++++ src/prices/prices.service.ts | 127 +++++++++ src/prices/ticker-map.spec.ts | 57 ++++ src/prices/ticker-map.ts | 54 ++++ 10 files changed, 807 insertions(+) create mode 100644 src/prices/prices.controller.spec.ts create mode 100644 src/prices/prices.controller.ts create mode 100644 src/prices/prices.module.ts create mode 100644 src/prices/prices.service.spec.ts create mode 100644 src/prices/prices.service.ts create mode 100644 src/prices/ticker-map.spec.ts create mode 100644 src/prices/ticker-map.ts 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/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..b51271d --- /dev/null +++ b/src/prices/prices.controller.spec.ts @@ -0,0 +1,121 @@ +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 { 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('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, + prices_stale: 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.prices_stale).toBe(true); + }); + }); +}); diff --git a/src/prices/prices.controller.ts b/src/prices/prices.controller.ts new file mode 100644 index 0000000..9bffb1b --- /dev/null +++ b/src/prices/prices.controller.ts @@ -0,0 +1,49 @@ +import { Controller, Get, Header, Query } from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { PricesService } from './prices.service'; +import { 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) { + const tickers = tickersStr + ? tickersStr + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : 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(), + prices_stale: 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..d4b7ef1 --- /dev/null +++ b/src/prices/prices.service.spec.ts @@ -0,0 +1,374 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { 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 stack refreshes while a fresh in-flight refresh is already running', async () => { + fetchMock.mockResolvedValue(cgOk(allPricesPayload())); + const service = makeService(); + await service.getPrices(); // refresh #1 → cache fresh + + const gate = deferred(); + fetchMock.mockImplementationOnce(() => gate.promise); + + jest.advanceTimersByTime(TTL_MS / 2); // still well within the TTL + expect(service.isStale()).toBe(false); + + const first = service.getPrices(); // no in-flight → starts the shared refresh + const second = service.getPrices(); // rides the in-flight refresh + const third = service.getPrices(['wbtc']); + gate.resolve(cgOk(allPricesPayload())); + const [r1, r2, r3] = await Promise.all([first, second, third]); + + expect(fetchMock).toHaveBeenCalledTimes(2); // one shared refresh, not four + 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 }, + }; + 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('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..91f7c8e --- /dev/null +++ b/src/prices/prices.service.ts @@ -0,0 +1,127 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + BRIDGE_TICKERS, + PRICE_ID_BY_TICKER, + tickerToPriceId, +} 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 + +/** + * 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 OnModuleDestroy { + private readonly logger = new Logger(PricesService.name); + private readonly ttlMs: number; + private prices: Record = {}; + private fetchedAt = 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; only staleness triggers a new + // fetch so sequential traffic stays at one CG call per TTL. + if (this.isStale()) { + 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; + this.inFlight = this.doRefresh().finally(() => { + this.inFlight = undefined; + }); + return this.inFlight; + } + + private async doRefresh(): Promise { + const ids = BRIDGE_TICKERS.map((t) => PRICE_ID_BY_TICKER[t]); + 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 = {}; + for (const ticker of BRIDGE_TICKERS) { + const id = tickerToPriceId(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()]; From 5600b103f20da4b0efb5dcb6489d689a83639c5a Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Wed, 16 Sep 2026 00:12:28 +0400 Subject: [PATCH 3/4] fix(prices): honor TTL on the request path; spec asserts the fixed contract --- src/prices/prices.service.spec.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/prices/prices.service.spec.ts b/src/prices/prices.service.spec.ts index d4b7ef1..689f784 100644 --- a/src/prices/prices.service.spec.ts +++ b/src/prices/prices.service.spec.ts @@ -198,24 +198,20 @@ describe('PricesService', () => { expect(service.isStale()).toBe(false); }); - it('does not stack refreshes while a fresh in-flight refresh is already running', async () => { + 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 - const gate = deferred(); - fetchMock.mockImplementationOnce(() => gate.promise); - jest.advanceTimersByTime(TTL_MS / 2); // still well within the TTL expect(service.isStale()).toBe(false); - const first = service.getPrices(); // no in-flight → starts the shared refresh - const second = service.getPrices(); // rides the in-flight refresh + const first = service.getPrices(); // fresh → served from the cached map + const second = service.getPrices(); // no refresh, same cached map const third = service.getPrices(['wbtc']); - gate.resolve(cgOk(allPricesPayload())); const [r1, r2, r3] = await Promise.all([first, second, third]); - expect(fetchMock).toHaveBeenCalledTimes(2); // one shared refresh, not four + 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 }); From d55e6e1ce529a9052a9a53a999cdc4a29222b702 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Wed, 16 Sep 2026 00:45:10 +0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(prices):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20failed-refresh=20backoff,=20filter=20cap,=20camelCase,=20nod?= =?UTF-8?q?e=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backoff: lastAttemptAt + MIN_RETRY_MS gates request-path retries, so a CG outage costs at most one upstream call per 30s instead of one per request - GET /prices caps the ?tickers filter at the covered ticker count - /prices/coverage: prices_stale → pricesStale (camelCase consistency) - drop the tickerToPriceId() non-null assertion (iterate map entries) - PricesService implements OnModuleInit (hook drift now caught by tsc) - .nvmrc pins 20.18; CI reads node-version-file: .nvmrc (single source) --- .github/workflows/ci.yml | 2 +- .nvmrc | 2 +- src/prices/prices.controller.spec.ts | 23 +++++++++++++--- src/prices/prices.controller.ts | 7 +++-- src/prices/prices.service.spec.ts | 30 ++++++++++++++++++++- src/prices/prices.service.ts | 39 ++++++++++++++++++---------- 6 files changed, 82 insertions(+), 21 deletions(-) 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 index 209e3ef..10fef25 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +20.18 diff --git a/src/prices/prices.controller.spec.ts b/src/prices/prices.controller.spec.ts index b51271d..7a63fe9 100644 --- a/src/prices/prices.controller.spec.ts +++ b/src/prices/prices.controller.spec.ts @@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing'; import request from 'supertest'; import { PricesController } from './prices.controller'; import { PricesService } from './prices.service'; -import { PRICE_ID_BY_TICKER } from './ticker-map'; +import { BRIDGE_TICKERS, PRICE_ID_BY_TICKER } from './ticker-map'; describe('PricesController', () => { let app: INestApplication; @@ -77,6 +77,23 @@ describe('PricesController', () => { 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 }); @@ -99,7 +116,7 @@ describe('PricesController', () => { expect(response.body).toEqual({ covered: 36, - prices_stale: false, + pricesStale: false, map: PRICE_ID_BY_TICKER, }); expect(coveredTickerCountMock).toHaveBeenCalledTimes(1); @@ -115,7 +132,7 @@ describe('PricesController', () => { .expect(200); expect(response.headers['access-control-allow-origin']).toBe('*'); - expect(response.body.prices_stale).toBe(true); + expect(response.body.pricesStale).toBe(true); }); }); }); diff --git a/src/prices/prices.controller.ts b/src/prices/prices.controller.ts index 9bffb1b..5437f99 100644 --- a/src/prices/prices.controller.ts +++ b/src/prices/prices.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Header, Query } from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { PricesService } from './prices.service'; -import { PRICE_ID_BY_TICKER } from './ticker-map'; +import { BRIDGE_TICKERS, PRICE_ID_BY_TICKER } from './ticker-map'; /** * USD prices for the full bridge asset set (crypto + 20 xStocks). @@ -25,11 +25,14 @@ export class PricesController { }) @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); } @@ -42,7 +45,7 @@ export class PricesController { coverage() { return { covered: this.prices.coveredTickerCount(), - prices_stale: this.prices.isStale(), + pricesStale: this.prices.isStale(), map: PRICE_ID_BY_TICKER, }; } diff --git a/src/prices/prices.service.spec.ts b/src/prices/prices.service.spec.ts index 689f784..76b3ba3 100644 --- a/src/prices/prices.service.spec.ts +++ b/src/prices/prices.service.spec.ts @@ -1,6 +1,6 @@ import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { PricesService } from './prices.service'; +import { MIN_RETRY_MS, PricesService } from './prices.service'; import { BRIDGE_TICKERS, PRICE_ID_BY_TICKER } from './ticker-map'; const TTL_MS = 600_000; @@ -248,6 +248,7 @@ describe('PricesService', () => { ...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(); @@ -299,6 +300,33 @@ describe('PricesService', () => { }); }); + 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())); diff --git a/src/prices/prices.service.ts b/src/prices/prices.service.ts index 91f7c8e..469a0f2 100644 --- a/src/prices/prices.service.ts +++ b/src/prices/prices.service.ts @@ -1,13 +1,19 @@ -import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { - BRIDGE_TICKERS, - PRICE_ID_BY_TICKER, - tickerToPriceId, -} from './ticker-map'; + 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. @@ -21,11 +27,12 @@ const DEFAULT_TTL_MS = 10 * 60_000; // one batched CG call per TTL, max * `stale`, not blank every ticker. */ @Injectable() -export class PricesService implements OnModuleDestroy { +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; @@ -53,9 +60,10 @@ export class PricesService implements OnModuleDestroy { * before the first successful fetch. */ async getPrices(tickers?: string[]): Promise> { - // refresh() dedupes concurrent callers; only staleness triggers a new - // fetch so sequential traffic stays at one CG call per TTL. - if (this.isStale()) { + // 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(() => {}); @@ -80,6 +88,10 @@ export class PricesService implements OnModuleDestroy { 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; }); @@ -87,7 +99,7 @@ export class PricesService implements OnModuleDestroy { } private async doRefresh(): Promise { - const ids = BRIDGE_TICKERS.map((t) => PRICE_ID_BY_TICKER[t]); + 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); @@ -102,8 +114,9 @@ export class PricesService implements OnModuleDestroy { } const byId: Record = await response.json(); const next: Record = {}; - for (const ticker of BRIDGE_TICKERS) { - const id = tickerToPriceId(ticker)!; + // 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;