Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 20
node-version-file: .nvmrc
cache: npm

- name: Install dependencies
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
20.18
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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**.
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -24,6 +25,7 @@ import configuration, { configValidationSchema } from './config/configuration';
UpstreamModule,
ChainModule,
IpfsModule,
PricesModule,
],
controllers: [BatchController, MintiniController],
providers: [PriceService, { provide: APP_GUARD, useClass: ThrottlerGuard }],
Expand Down
10 changes: 10 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand All @@ -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),
Expand Down
138 changes: 138 additions & 0 deletions src/prices/prices.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
52 changes: 52 additions & 0 deletions src/prices/prices.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
9 changes: 9 additions & 0 deletions src/prices/prices.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading