Skip to content

Commit 4d8f078

Browse files
committed
fix(market): support CoinGecko free Demo API keys
CoinGecko's free Demo plan and paid Pro plan share the CG- key prefix but require different hosts and auth headers. The code assumed any key was a Pro key and always called pro-api.coingecko.com with x-cg-pro-api-key, so a free Demo key failed with HTTP 400 (error 10011) across all 4 crypto seeders and the server market RPC fetcher. Add an explicit COINGECKO_DEMO_API_KEY env var that routes to api.coingecko.com with x-cg-demo-api-key. Pro takes precedence when both are set, so existing Pro deployments are unaffected; no key still falls back to the public endpoint. Extracted the tier resolution into a shared coingeckoEndpoint() helper (scripts) and a typed local helper (server).
1 parent 6a4d298 commit 4d8f078

7 files changed

Lines changed: 76 additions & 33 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,15 @@ ALPHA_VANTAGE_API_KEY=
558558
# CoinGecko — crypto market data (server market RPC + seed-crypto-sectors).
559559
# Optional; unauthenticated requests work at a lower rate limit.
560560
# Register at: https://www.coingecko.com/en/api
561+
#
562+
# Two paid/free tiers use DIFFERENT hosts + auth headers (the CG- key prefix is
563+
# shared, so the tier cannot be auto-detected — set whichever key you have):
564+
# - Pro (paid): COINGECKO_API_KEY -> pro-api.coingecko.com (x-cg-pro-api-key)
565+
# - Demo (free): COINGECKO_DEMO_API_KEY -> api.coingecko.com (x-cg-demo-api-key)
566+
# Pro takes precedence if both are set. A Demo key in COINGECKO_API_KEY fails
567+
# with HTTP 400 — put free Demo keys in COINGECKO_DEMO_API_KEY instead.
561568
COINGECKO_API_KEY=
569+
COINGECKO_DEMO_API_KEY=
562570

563571

564572
# ------ Energy Data (additional) ------

scripts/_seed-utils.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,40 @@ const __seed_dirname = dirname(fileURLToPath(import.meta.url));
1717

1818
export { CHROME_UA };
1919

20+
/**
21+
* Resolve the CoinGecko base URL + auth header for the configured key tier.
22+
*
23+
* CoinGecko's free **Demo** plan and paid **Pro** plan share the `CG-` key
24+
* prefix but use *different* hosts and auth headers — a Demo key sent to the
25+
* Pro host fails with `HTTP 400` (error 10011: "change your root URL from
26+
* pro-api.coingecko.com to api.coingecko.com"), and a Pro key on the public
27+
* host is unauthenticated. The key string alone can't tell the tiers apart,
28+
* so the tier is selected explicitly by which env var is set:
29+
*
30+
* - `COINGECKO_API_KEY` → Pro (pro-api.coingecko.com, x-cg-pro-api-key)
31+
* - `COINGECKO_DEMO_API_KEY` → Demo (api.coingecko.com, x-cg-demo-api-key)
32+
* - neither → keyless public endpoint (shared IP, 429-prone)
33+
*
34+
* Pro takes precedence so existing Pro deployments are unaffected.
35+
*
36+
* @param {Record<string, string>} [extraHeaders] merged into the returned headers
37+
* @returns {{ baseUrl: string, headers: Record<string, string>, tier: 'pro' | 'demo' | 'keyless' }}
38+
*/
39+
export function coingeckoEndpoint(extraHeaders = {}) {
40+
const proKey = process.env.COINGECKO_API_KEY;
41+
const demoKey = process.env.COINGECKO_DEMO_API_KEY;
42+
const headers = { Accept: 'application/json', 'User-Agent': CHROME_UA, ...extraHeaders };
43+
if (proKey) {
44+
headers['x-cg-pro-api-key'] = proKey;
45+
return { baseUrl: 'https://pro-api.coingecko.com/api/v3', headers, tier: 'pro' };
46+
}
47+
if (demoKey) {
48+
headers['x-cg-demo-api-key'] = demoKey;
49+
return { baseUrl: 'https://api.coingecko.com/api/v3', headers, tier: 'demo' };
50+
}
51+
return { baseUrl: 'https://api.coingecko.com/api/v3', headers, tier: 'keyless' };
52+
}
53+
2054
/**
2155
* Unwrap fetch / network errors so log lines surface the actual cause
2256
* (DNS / TCP reset / TLS abort) instead of undici's bare "fetch failed".

scripts/seed-crypto-quotes.mjs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22

3-
import { loadEnvFile, loadSharedConfig, CHROME_UA, runSeed, sleep, fetchCoinPaprikaTickersById } from './_seed-utils.mjs';
3+
import { loadEnvFile, loadSharedConfig, CHROME_UA, runSeed, sleep, fetchCoinPaprikaTickersById, coingeckoEndpoint } from './_seed-utils.mjs';
44

55
const cryptoConfig = loadSharedConfig('crypto.json');
66

@@ -34,13 +34,8 @@ const COINPAPRIKA_ID_MAP = cryptoConfig.coinpaprika;
3434

3535
async function fetchFromCoinGecko() {
3636
const ids = CRYPTO_IDS.join(',');
37-
const apiKey = process.env.COINGECKO_API_KEY;
38-
const baseUrl = apiKey
39-
? 'https://pro-api.coingecko.com/api/v3'
40-
: 'https://api.coingecko.com/api/v3';
37+
const { baseUrl, headers } = coingeckoEndpoint();
4138
const url = `${baseUrl}/coins/markets?vs_currency=usd&ids=${ids}&order=market_cap_desc&sparkline=true&price_change_percentage=24h`;
42-
const headers = { Accept: 'application/json', 'User-Agent': CHROME_UA };
43-
if (apiKey) headers['x-cg-pro-api-key'] = apiKey;
4439

4540
// Capped at 2 attempts (10+20=30s budget) so the fallback path itself
4641
// cannot recreate the 150s>120s bundle-timeout overrun this PR fixes.

scripts/seed-crypto-sectors.mjs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22

3-
import { loadEnvFile, loadSharedConfig, CHROME_UA, runSeed, sleep } from './_seed-utils.mjs';
3+
import { loadEnvFile, loadSharedConfig, CHROME_UA, runSeed, sleep, coingeckoEndpoint } from './_seed-utils.mjs';
44

55
const sectorsConfig = loadSharedConfig('crypto-sectors.json');
66

@@ -29,11 +29,8 @@ async function fetchWithRateLimitRetry(url, maxAttempts = 5, headers = { Accept:
2929
async function fetchSectorData() {
3030
const allIds = [...new Set(SECTORS.flatMap(s => s.tokens))];
3131

32-
const apiKey = process.env.COINGECKO_API_KEY;
33-
const baseUrl = apiKey ? 'https://pro-api.coingecko.com/api/v3' : 'https://api.coingecko.com/api/v3';
32+
const { baseUrl, headers } = coingeckoEndpoint();
3433
const url = `${baseUrl}/coins/markets?vs_currency=usd&ids=${allIds.join(',')}&order=market_cap_desc&sparkline=false&price_change_percentage=24h`;
35-
const headers = { Accept: 'application/json', 'User-Agent': CHROME_UA };
36-
if (apiKey) headers['x-cg-pro-api-key'] = apiKey;
3734

3835
const resp = await fetchWithRateLimitRetry(url, 5, headers);
3936
const data = await resp.json();

scripts/seed-stablecoin-markets.mjs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22

3-
import { loadEnvFile, loadSharedConfig, CHROME_UA, runSeed, sleep, fetchCoinPaprikaTickersById } from './_seed-utils.mjs';
3+
import { loadEnvFile, loadSharedConfig, CHROME_UA, runSeed, sleep, fetchCoinPaprikaTickersById, coingeckoEndpoint } from './_seed-utils.mjs';
44

55
const stablecoinConfig = loadSharedConfig('stablecoins.json');
66

@@ -32,13 +32,8 @@ async function fetchWithRateLimitRetry(url, maxAttempts = 5, headers = { Accept:
3232
const COINPAPRIKA_ID_MAP = stablecoinConfig.coinpaprika;
3333

3434
async function fetchFromCoinGecko() {
35-
const apiKey = process.env.COINGECKO_API_KEY;
36-
const baseUrl = apiKey
37-
? 'https://pro-api.coingecko.com/api/v3'
38-
: 'https://api.coingecko.com/api/v3';
35+
const { baseUrl, headers } = coingeckoEndpoint();
3936
const url = `${baseUrl}/coins/markets?vs_currency=usd&ids=${STABLECOIN_IDS}&order=market_cap_desc&sparkline=false&price_change_percentage=7d`;
40-
const headers = { Accept: 'application/json', 'User-Agent': CHROME_UA };
41-
if (apiKey) headers['x-cg-pro-api-key'] = apiKey;
4237

4338
const resp = await fetchWithRateLimitRetry(url, 5, headers);
4439
const data = await resp.json();

scripts/seed-token-panels.mjs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env node
22

3-
import { loadEnvFile, loadSharedConfig, CHROME_UA, runSeed, sleep, fetchCoinPaprikaTickersById } from './_seed-utils.mjs';
3+
import { loadEnvFile, loadSharedConfig, CHROME_UA, runSeed, sleep, fetchCoinPaprikaTickersById, coingeckoEndpoint } from './_seed-utils.mjs';
44

55
const defiConfig = loadSharedConfig('defi-tokens.json');
66
const aiConfig = loadSharedConfig('ai-tokens.json');
@@ -32,11 +32,8 @@ async function fetchWithRateLimitRetry(url, maxAttempts = 5, headers = { Accept:
3232
}
3333

3434
async function fetchFromCoinGecko() {
35-
const apiKey = process.env.COINGECKO_API_KEY;
36-
const baseUrl = apiKey ? 'https://pro-api.coingecko.com/api/v3' : 'https://api.coingecko.com/api/v3';
35+
const { baseUrl, headers } = coingeckoEndpoint();
3736
const url = `${baseUrl}/coins/markets?vs_currency=usd&ids=${ALL_IDS.join(',')}&order=market_cap_desc&sparkline=false&price_change_percentage=24h,7d`;
38-
const headers = { Accept: 'application/json', 'User-Agent': CHROME_UA };
39-
if (apiKey) headers['x-cg-pro-api-key'] = apiKey;
4037

4138
const resp = await fetchWithRateLimitRetry(url, 5, headers);
4239
const data = await resp.json();

server/worldmonitor/market/v1/_shared.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -326,19 +326,36 @@ export async function fetchYahooQuote(
326326
// CoinGecko fetcher
327327
// ========================================================================
328328

329-
export async function fetchCoinGeckoMarkets(
330-
ids: string[],
331-
): Promise<CoinGeckoMarketItem[]> {
332-
const apiKey = process.env.COINGECKO_API_KEY;
333-
const baseUrl = apiKey
334-
? 'https://pro-api.coingecko.com/api/v3'
335-
: 'https://api.coingecko.com/api/v3';
336-
const url = `${baseUrl}/coins/markets?vs_currency=usd&ids=${ids.join(',')}&order=market_cap_desc&sparkline=true&price_change_percentage=24h`;
329+
/**
330+
* Resolve the CoinGecko base URL + auth header for the configured key tier.
331+
*
332+
* CoinGecko's free Demo plan and paid Pro plan share the `CG-` key prefix but
333+
* use different hosts and auth headers — a Demo key sent to the Pro host fails
334+
* with HTTP 400 (error 10011: "change your root URL from pro-api.coingecko.com
335+
* to api.coingecko.com"). The key string can't reveal the tier, so it is
336+
* selected explicitly by which env var is set; Pro wins so existing Pro
337+
* deployments are unaffected, and no key falls back to the public endpoint.
338+
*/
339+
function coingeckoEndpoint(): { baseUrl: string; headers: Record<string, string> } {
340+
const proKey = process.env.COINGECKO_API_KEY;
341+
const demoKey = process.env.COINGECKO_DEMO_API_KEY;
337342
const headers: Record<string, string> = {
338343
Accept: 'application/json',
339344
'User-Agent': CHROME_UA,
340345
};
341-
if (apiKey) headers['x-cg-pro-api-key'] = apiKey;
346+
if (proKey) {
347+
headers['x-cg-pro-api-key'] = proKey;
348+
return { baseUrl: 'https://pro-api.coingecko.com/api/v3', headers };
349+
}
350+
if (demoKey) headers['x-cg-demo-api-key'] = demoKey;
351+
return { baseUrl: 'https://api.coingecko.com/api/v3', headers };
352+
}
353+
354+
export async function fetchCoinGeckoMarkets(
355+
ids: string[],
356+
): Promise<CoinGeckoMarketItem[]> {
357+
const { baseUrl, headers } = coingeckoEndpoint();
358+
const url = `${baseUrl}/coins/markets?vs_currency=usd&ids=${ids.join(',')}&order=market_cap_desc&sparkline=true&price_change_percentage=24h`;
342359

343360
const resp = await fetch(url, {
344361
headers,

0 commit comments

Comments
 (0)