Skip to content

feat: /prices — USD prices for the full bridge asset set - #2

Open
erubboli wants to merge 4 commits into
mainfrom
feat/prices
Open

erubboli wants to merge 4 commits into
mainfrom
feat/prices

Conversation

@erubboli

Copy link
Copy Markdown
Member

Covers 36/36 bridgeable tickers from bridge.mintlayer.org agents-config: 16 crypto/DeFi (already known ids) + 20 xStocks tokenized equities the frontend never had prices for (waaplx→apple-xstock … wxomx→exxon-mobil-xstock, ids verified against CoinGecko live).

  • GET /prices (optional ?tickers= filter) — {ticker: usd}, CORS-open
  • GET /prices/coverage — ticker→id map + staleness
  • One server-side batched CG call per 10-min TTL (replaces per-visitor CG calls); CG_API_KEY env optional; stale-on-error keeps serving the last good map
  • 30 new tests (139 total); tdd-review caught + fixed a TTL-defeat bug on the request path

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.
@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

🔍 OpenCodeReview found 4 issue(s) in this PR.

  • ✅ Successfully posted inline: 2 comment(s)
  • 📋 Routed to summary by policy: 2 comment(s)

maintainability · low

📄 src/prices/prices.service.ts (L110-L110)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

The 15s upstream timeout is hardcoded while neighboring timeouts in configuration.ts (e.g. ipfs timeoutMs) are env-configurable, and other tunables of this service (ttlMs) already are. Consider a PRICES_FETCH_TIMEOUT_MS config entry for consistency.


maintainability · low

📄 src/prices/ticker-map.ts (L53-L54)

⚠️ GitHub could not post this as an inline comment: Routed to summary (severity low · category maintainability)

tickerToPriceId is only referenced by its own spec file; all production code (prices.controller.ts, prices.service.ts) accesses PRICE_ID_BY_TICKER directly (controller even exports the whole map via /coverage). This exported helper adds an unused layer of indirection; consider removing it and asserting the map contents directly in tests, or keep it and use it in the service for consistency.

💡 Suggested Change

Before:

export const tickerToPriceId = (ticker: string): string | undefined =>
  PRICE_ID_BY_TICKER[ticker.toLowerCase()];

After:

// Remove if no production caller is planned:
// export const tickerToPriceId = ...

Comment thread src/prices/prices.service.ts Outdated
Comment on lines +58 to +61
if (this.isStale()) {
void this.refresh();
}
if (this.inFlight) await this.inFlight.catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
There is no backoff on failed refreshes: fetchedAt is only updated on success, so during a CoinGecko outage every incoming request sees isStale() and joins/starts a refresh. Once a failed fetch finishes, the very next request immediately retries — sustained outage means one 15s-timeout upstream call per request, and each request awaits the in-flight fetch, adding up to 15s latency. Consider a lastAttemptAt-based throttle (e.g. min retry interval) or a negative cache for failures.

Suggestion:

Suggested change
if (this.isStale()) {
void this.refresh();
}
if (this.inFlight) await this.inFlight.catch(() => {});
if (this.isStale() && Date.now() - this.lastAttemptAt > MIN_RETRY_MS) {
void this.refresh();
}

…elCase, node pin

- 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)
Comment on lines +103 to +105
const apiKey = this.config.get<string>('cg.apiKey', '');
const headers: Record<string, string> = { accept: 'application/json' };
if (apiKey) headers['x-cg-demo-api-key'] = String(apiKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The config comment declares CG_API_KEY as an 'Optional CoinGecko demo/pro key', but this code always sends it as x-cg-demo-api-key against the free api.coingecko.com host. A pro-tier key (which requires the pro-api.coingecko.com host and the x-cg-pro-api-key header) will be silently misused — requests will look anonymous and get demo-tier rate limits or 401s. Either restrict the config to a demo key (update the comment/validation) or branch on key type to set the correct host and header.

Suggestion:

Suggested change
const apiKey = this.config.get<string>('cg.apiKey', '');
const headers: Record<string, string> = { accept: 'application/json' };
if (apiKey) headers['x-cg-demo-api-key'] = String(apiKey);
const apiKey = this.config.get<string>('cg.apiKey', '');
const headers: Record<string, string> = { accept: 'application/json' };
if (apiKey) headers['x-cg-demo-api-key'] = String(apiKey); // demo-tier only

Comment on lines +125 to +128
if (Object.keys(next).length === 0) {
throw new Error('CoinGecko returned no usable prices');
}
this.prices = next;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
An upstream response covering only a subset of ids silently replaces the entire price map. On a partial CG response (rate-limited batch truncation, an id temporarily delisted, etc.), all missing tickers are dropped from what every client sees until a later fully-successful refresh, and the only signal is a debug log. Consider merging next into this.prices for ids still present, or at least warn when coverage drops below a threshold (e.g. next size vs BRIDGE_TICKERS.length).

Suggestion:

Suggested change
if (Object.keys(next).length === 0) {
throw new Error('CoinGecko returned no usable prices');
}
this.prices = next;
const coverage = Object.keys(next).length;
if (coverage < BRIDGE_TICKERS.length) {
this.logger.warn(
`partial CoinGecko coverage: ${coverage}/${BRIDGE_TICKERS.length} ids returned; merging with previous map`,
);
}
if (coverage > 0) {
this.prices = { ...this.prices, ...next };
this.fetchedAt = Date.now();
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant