From 650a81cabd2a2e31ed8909d0fd900bf4802cd876 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 21:28:37 +0200 Subject: [PATCH 1/3] Drop the empty-string fallback on the Pro API key The pro-tier branch of COINGECKO_CLIENT sent \`'x-cg-pro-api-key': CONFIG.coingeckoApiKey ?? ''\`. The bootstrap check at the top of the module already guarantees one of the two env vars is set, so the only way this branch ran with an empty key was a misconfiguration that escaped the bootstrap (e.g. an upstream bug, an environment leak). The empty header would then turn into an upstream 401 that looks like a CoinGecko outage instead of the local configuration problem it really is. Explicit throw + plain string assignment removes the silent fallback and surfaces the misconfiguration where it actually happens. --- api.config.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/api.config.ts b/api.config.ts index c7c483b..eed9fdc 100644 --- a/api.config.ts +++ b/api.config.ts @@ -13,7 +13,7 @@ if (!isMainnet && process.env.RPC_URL_TESTNET === undefined) throw new Error('RP // must be set; otherwise the upstream CoinGecko calls are anonymous and fail // under load. if (!process.env.COINGECKO_API_KEY && !process.env.COINGECKO_BASE_URL) { - throw new Error('COINGECKO_API_KEY or COINGECKO_BASE_URL must be set'); + throw new Error('CoinGecko is not configured: set COINGECKO_BASE_URL or COINGECKO_API_KEY'); } // Config type @@ -137,9 +137,17 @@ export const COINGECKO_CLIENT = (query: string) => { if (CONFIG.coingeckoBaseUrl) { return fetch(`${CONFIG.coingeckoBaseUrl}${query}`); } + // Bootstrap above guarantees one of the two is set, so reaching this + // branch means coingeckoApiKey is defined. Hard-fail anyway instead of + // using a `?? ''` default — sending an empty auth header would silently + // turn into 401 at the upstream and look like a CoinGecko outage rather + // than the misconfiguration it actually is. + if (!CONFIG.coingeckoApiKey) { + throw new Error('CoinGecko is not configured: set COINGECKO_BASE_URL or COINGECKO_API_KEY'); + } const uri: string = `https://pro-api.coingecko.com${query}`; return fetch(uri, { - headers: { 'x-cg-pro-api-key': CONFIG.coingeckoApiKey ?? '' }, + headers: { 'x-cg-pro-api-key': CONFIG.coingeckoApiKey }, }); }; From 36f1f62daa05c4c224a31bcbef9ccdb36aecf734 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 21:40:40 +0200 Subject: [PATCH 2/3] Drop the direct-Pro code path; everything goes via pricing-proxy COINGECKO_API_KEY is no longer read by this service. The proxy stack holds the upstream key. COINGECKO_CLIENT is now a one-liner that always hits the proxy and the bootstrap refuses to start without COINGECKO_BASE_URL. No more two-branch logic, no more dead code. --- .env.example | 8 ++------ api.config.ts | 48 ++++++++++-------------------------------------- 2 files changed, 12 insertions(+), 44 deletions(-) diff --git a/.env.example b/.env.example index 39915e2..f4827bc 100644 --- a/.env.example +++ b/.env.example @@ -8,12 +8,8 @@ CONFIG_CHAIN=testnet RPC_URL_MAINNET=https://rpc.citreascan.com RPC_URL_TESTNET=https://rpc.testnet.citreascan.com -# CoinGecko: set EITHER a base URL pointing at a fronting pricing proxy (which -# injects the upstream key itself, recommended for in-cluster deployments) OR a -# direct Pro key. Setting only COINGECKO_BASE_URL is the cleanest setup; setting -# only COINGECKO_API_KEY routes through pro-api.coingecko.com directly. -# COINGECKO_BASE_URL=http://pricing-proxy:8080/coingecko -COINGECKO_API_KEY=[API-KEY] +# CoinGecko: all upstream calls go through the in-cluster pricing proxy. +COINGECKO_BASE_URL=http://pricing-proxy:8080/coingecko TELEGRAM_BOT_TOKEN=[API-KEY] TELEGRAM_GROUPS_JSON=telegram.groups.json diff --git a/api.config.ts b/api.config.ts index eed9fdc..1d4f522 100644 --- a/api.config.ts +++ b/api.config.ts @@ -9,11 +9,10 @@ dotenv.config(); const isMainnet = process.env.CONFIG_CHAIN === 'mainnet'; if (isMainnet && process.env.RPC_URL_MAINNET === undefined) throw new Error('RPC_URL_MAINNET not available'); if (!isMainnet && process.env.RPC_URL_TESTNET === undefined) throw new Error('RPC_URL_TESTNET not available'); -// Either a key for direct Pro access OR a base URL for a fronting pricing proxy -// must be set; otherwise the upstream CoinGecko calls are anonymous and fail -// under load. -if (!process.env.COINGECKO_API_KEY && !process.env.COINGECKO_BASE_URL) { - throw new Error('CoinGecko is not configured: set COINGECKO_BASE_URL or COINGECKO_API_KEY'); +// The api always talks to the in-cluster pricing proxy. The upstream Pro key +// lives in the proxy stack, not here. +if (!process.env.COINGECKO_BASE_URL) { + throw new Error('COINGECKO_BASE_URL is not set'); } // Config type @@ -21,8 +20,7 @@ export type ConfigType = { app: string; indexer: string; indexerFallback: string; - coingeckoApiKey: string | undefined; - coingeckoBaseUrl: string | undefined; + coingeckoBaseUrl: string; chain: Chain; network: { mainnet: string; @@ -47,8 +45,7 @@ export const CONFIG: ConfigType = { app: process.env.CONFIG_APP_URL, indexer: process.env.CONFIG_INDEXER_URL, indexerFallback: process.env.CONFIG_INDEXER_FALLBACK_URL, - coingeckoApiKey: process.env.COINGECKO_API_KEY || undefined, - coingeckoBaseUrl: process.env.COINGECKO_BASE_URL || undefined, + coingeckoBaseUrl: process.env.COINGECKO_BASE_URL, chain: isMainnet ? mainnet : testnet, network: { mainnet: process.env.RPC_URL_MAINNET, @@ -73,7 +70,6 @@ export const CONFIG: ConfigType = { }; const SENSITIVE_KEYS = new Set([ - 'coingeckoApiKey', 'network.mainnet', 'network.testnet', 'telegram.botToken', @@ -83,10 +79,6 @@ const SENSITIVE_KEYS = new Set([ 'twitter.accessSecret', ]); -// `coingeckoBaseUrl` is intentionally NOT redacted — it is a non-secret pointer -// (typically the in-cluster pricing proxy origin) and useful at startup for -// confirming routing. - function redactConfig(config: T): T { return walkRedact(config, '') as T; } @@ -126,30 +118,10 @@ export const VIEM_CONFIG = createPublicClient({ }); // COINGECKO CLIENT -// -// Resolution priority: -// 1. COINGECKO_BASE_URL set → trust the caller (typically a pricing proxy that -// injects the upstream key itself); send no auth. -// 2. COINGECKO_API_KEY set → Pro tier: pro-api.coingecko.com with the -// `x-cg-pro-api-key` header. The earlier query-string form is supported by -// CoinGecko but cache-poisons proxies that key on the URL. -export const COINGECKO_CLIENT = (query: string) => { - if (CONFIG.coingeckoBaseUrl) { - return fetch(`${CONFIG.coingeckoBaseUrl}${query}`); - } - // Bootstrap above guarantees one of the two is set, so reaching this - // branch means coingeckoApiKey is defined. Hard-fail anyway instead of - // using a `?? ''` default — sending an empty auth header would silently - // turn into 401 at the upstream and look like a CoinGecko outage rather - // than the misconfiguration it actually is. - if (!CONFIG.coingeckoApiKey) { - throw new Error('CoinGecko is not configured: set COINGECKO_BASE_URL or COINGECKO_API_KEY'); - } - const uri: string = `https://pro-api.coingecko.com${query}`; - return fetch(uri, { - headers: { 'x-cg-pro-api-key': CONFIG.coingeckoApiKey }, - }); -}; +// All CoinGecko traffic goes through the in-cluster pricing proxy. The proxy +// holds the upstream key and validates upstream errors, so the api itself +// never talks to pro-api.coingecko.com directly. +export const COINGECKO_CLIENT = (query: string) => fetch(`${CONFIG.coingeckoBaseUrl}${query}`); export const PROTOCOL_STABLECOIN_SYMBOL = 'JUSD'; export const PROTOCOL_STABLECOIN_NAME = 'Juice Dollar'; From 4c5ee74176acd4bf7f5a71e8045abbf179418344 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 22:25:42 +0200 Subject: [PATCH 3/3] Make COINGECKO_API_KEY orthogonal: optional header, never a fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api can now also be pointed at pro-api.coingecko.com directly by setting COINGECKO_API_KEY alongside COINGECKO_BASE_URL — the key is attached as the x-cg-pro-api-key header on every request when set, no two-branch logic, no fallback. In the DFX setup COINGECKO_API_KEY stays unset because the proxy injects its own key. README documents the pattern and links the proxy reference implementation at github.com/DFXswiss/pricing-proxy. --- .env.example | 12 +++++++++++- README.md | 34 +++++++++++++++++++++++++++++++++- api.config.ts | 28 ++++++++++++++++++++++------ 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index f4827bc..a8e8239 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,18 @@ CONFIG_CHAIN=testnet RPC_URL_MAINNET=https://rpc.citreascan.com RPC_URL_TESTNET=https://rpc.testnet.citreascan.com -# CoinGecko: all upstream calls go through the in-cluster pricing proxy. +# CoinGecko Configuration. +# +# COINGECKO_BASE_URL: required. The origin the api calls. Recommended is +# the in-cluster pricing-proxy (https://github.com/DFXswiss/pricing-proxy), +# which holds the upstream Pro key and serves a 60 s shared cache. Anything +# CoinGecko-compatible works (pro-api.coingecko.com, api.coingecko.com, …). COINGECKO_BASE_URL=http://pricing-proxy:8080/coingecko +# +# COINGECKO_API_KEY: optional. If set, attached as `x-cg-pro-api-key` to +# every request. Leave unset when talking to the pricing-proxy (proxy injects +# its own key) or to the public host anonymously. +# COINGECKO_API_KEY= TELEGRAM_BOT_TOKEN=[API-KEY] TELEGRAM_GROUPS_JSON=telegram.groups.json diff --git a/README.md b/README.md index 247bd5c..0015c88 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,45 @@ CONFIG_CHAIN=mainnet RPC_URL_MAINNET=https://eth-mainnet.g.alchemy.com/v2/[API-KEY] RPC_URL_POLYGON=https://polygon-mainnet.g.alchemy.com/v2/[API-KEY] -COINGECKO_API_KEY=[API-KEY] +# See "CoinGecko" section below. +COINGECKO_BASE_URL=http://pricing-proxy:8080/coingecko +# COINGECKO_API_KEY= TELEGRAM_BOT_TOKEN=[API-KEY] TELEGRAM_GROUPS_JSON=telegram.groups.json TELEGRAM_IMAGES_DIR=./images ``` +## CoinGecko + +The api fetches token prices from a CoinGecko-compatible endpoint. +Configuration is two env vars: + +| Var | Required | Purpose | +|---|---|---| +| `COINGECKO_BASE_URL` | yes | Origin the api calls. | +| `COINGECKO_API_KEY` | no | Attached as the `x-cg-pro-api-key` header on every request when set. | + +The recommended deployment is the +[**pricing-proxy**](https://github.com/DFXswiss/pricing-proxy) — a small +caching reverse-proxy in front of CoinGecko Pro. It holds the upstream +key, serves a 60 s shared cache, validates upstream error envelopes, and +coalesces concurrent identical requests. When you use the proxy: + +```env +COINGECKO_BASE_URL=http://pricing-proxy:8080/coingecko +# COINGECKO_API_KEY left unset — the proxy injects its own key +``` + +Without the proxy, talk to CoinGecko directly: + +```env +COINGECKO_BASE_URL=https://pro-api.coingecko.com +COINGECKO_API_KEY=CG-xxxxxxxxxxxxxxxxxxxxxxxx +``` + +The api refuses to start without `COINGECKO_BASE_URL`. + ## Running the app ```bash diff --git a/api.config.ts b/api.config.ts index 1d4f522..2063b8e 100644 --- a/api.config.ts +++ b/api.config.ts @@ -9,8 +9,11 @@ dotenv.config(); const isMainnet = process.env.CONFIG_CHAIN === 'mainnet'; if (isMainnet && process.env.RPC_URL_MAINNET === undefined) throw new Error('RPC_URL_MAINNET not available'); if (!isMainnet && process.env.RPC_URL_TESTNET === undefined) throw new Error('RPC_URL_TESTNET not available'); -// The api always talks to the in-cluster pricing proxy. The upstream Pro key -// lives in the proxy stack, not here. +// COINGECKO_BASE_URL is the origin the api calls — typically the in-cluster +// pricing-proxy (https://github.com/DFXswiss/pricing-proxy), but any +// CoinGecko-compatible host works. COINGECKO_API_KEY is optional and is +// only attached as `x-cg-pro-api-key` on every request when set (proxy mode +// leaves it unset because the proxy injects its own key). if (!process.env.COINGECKO_BASE_URL) { throw new Error('COINGECKO_BASE_URL is not set'); } @@ -21,6 +24,7 @@ export type ConfigType = { indexer: string; indexerFallback: string; coingeckoBaseUrl: string; + coingeckoApiKey: string | undefined; chain: Chain; network: { mainnet: string; @@ -46,6 +50,7 @@ export const CONFIG: ConfigType = { indexer: process.env.CONFIG_INDEXER_URL, indexerFallback: process.env.CONFIG_INDEXER_FALLBACK_URL, coingeckoBaseUrl: process.env.COINGECKO_BASE_URL, + coingeckoApiKey: process.env.COINGECKO_API_KEY || undefined, chain: isMainnet ? mainnet : testnet, network: { mainnet: process.env.RPC_URL_MAINNET, @@ -70,6 +75,7 @@ export const CONFIG: ConfigType = { }; const SENSITIVE_KEYS = new Set([ + 'coingeckoApiKey', 'network.mainnet', 'network.testnet', 'telegram.botToken', @@ -118,10 +124,20 @@ export const VIEM_CONFIG = createPublicClient({ }); // COINGECKO CLIENT -// All CoinGecko traffic goes through the in-cluster pricing proxy. The proxy -// holds the upstream key and validates upstream errors, so the api itself -// never talks to pro-api.coingecko.com directly. -export const COINGECKO_CLIENT = (query: string) => fetch(`${CONFIG.coingeckoBaseUrl}${query}`); +// +// Calls go to whatever `COINGECKO_BASE_URL` points at. When the optional +// `COINGECKO_API_KEY` is set, it is attached as the `x-cg-pro-api-key` +// header — orthogonal to the base URL, never a fallback. The recommended +// deployment is the in-cluster pricing-proxy +// (https://github.com/DFXswiss/pricing-proxy), which injects its own key +// and leaves COINGECKO_API_KEY unset on every consumer. +export const COINGECKO_CLIENT = (query: string) => { + const headers: Record = { accept: 'application/json' }; + if (CONFIG.coingeckoApiKey) { + headers['x-cg-pro-api-key'] = CONFIG.coingeckoApiKey; + } + return fetch(`${CONFIG.coingeckoBaseUrl}${query}`, { headers }); +}; export const PROTOCOL_STABLECOIN_SYMBOL = 'JUSD'; export const PROTOCOL_STABLECOIN_NAME = 'Juice Dollar';