From 9f3df22e0353b2e2d18cebcfaa7510becd4ed13b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 21:06:38 +0200 Subject: [PATCH 1/3] Route CoinGecko via the in-cluster pricing proxy (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api was always going to pro-api.coingecko.com directly with a query-string-keyed Pro key. That works on its own but cache-poisons any HTTP-cache that keys on the URL, and it bypasses the central pricing proxy that already holds the upstream key, runs a 60 s shared cache, and validates upstream error envelopes. - COINGECKO_BASE_URL is now the preferred env var: when set, requests go to that origin (typically the pricing proxy) and no auth header is added — the proxy injects the key. - COINGECKO_API_KEY remains supported as a direct fallback. Calls now route to pro-api.coingecko.com via header (`x-cg-pro-api-key`) instead of the cache-unfriendly `x_cg_pro_api_key` query string. - Bootstrap now requires at least one of the two so the service does not silently come up unauthenticated. --- .env.example | 5 +++++ api.config.ts | 28 +++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 672ae40..5138f26 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ 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: 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] TELEGRAM_BOT_TOKEN=[API-KEY] diff --git a/api.config.ts b/api.config.ts index b7aa24d..b04ff4b 100644 --- a/api.config.ts +++ b/api.config.ts @@ -9,14 +9,20 @@ dotenv.config(); // Verify environment if (process.env.RPC_URL_MAINNET === undefined) throw new Error('RPC_URL_MAINNET not available'); if (process.env.RPC_URL_POLYGON === undefined) throw new Error('RPC_URL_POLYGON not available'); -if (process.env.COINGECKO_API_KEY === undefined) throw new Error('COINGECKO_API_KEY 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_API_KEY or COINGECKO_BASE_URL must be set'); +} // Config type export type ConfigType = { app: string; indexer: string; indexerFallback: string; - coingeckoApiKey: string; + coingeckoApiKey: string | undefined; + coingeckoBaseUrl: string | undefined; chain: Chain; network: { mainnet: string; @@ -42,7 +48,8 @@ export const CONFIG: ConfigType = { app: process.env.CONFIG_APP_URL || 'https://app.deuro.com', indexer: process.env.CONFIG_INDEXER_URL || 'https://ponder.deuro.com/', indexerFallback: process.env.CONFIG_INDEXER_FALLBACK_URL || 'https://dev.ponder.deuro.com/', - coingeckoApiKey: process.env.COINGECKO_API_KEY, + coingeckoApiKey: process.env.COINGECKO_API_KEY || undefined, + coingeckoBaseUrl: process.env.COINGECKO_BASE_URL || undefined, chain: process.env.CONFIG_CHAIN === 'polygon' ? polygon : mainnet, // @dev: default mainnet network: { mainnet: process.env.RPC_URL_MAINNET, @@ -112,10 +119,21 @@ 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) => { - const hasParams = query.includes('?'); + if (CONFIG.coingeckoBaseUrl) { + return fetch(`${CONFIG.coingeckoBaseUrl}${query}`); + } const uri: string = `https://pro-api.coingecko.com${query}`; - return fetch(`${uri}${hasParams ? '&' : '?'}x_cg_pro_api_key=${CONFIG.coingeckoApiKey}`); + return fetch(uri, { + headers: { 'x-cg-pro-api-key': CONFIG.coingeckoApiKey ?? '' }, + }); }; // Contract addresses for the active chain From ee6489217a85299dbafe9b79ec628880dcaa3c1c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 21:50:16 +0200 Subject: [PATCH 2/3] ci: add build & lint workflow for PRs and pushes (#109) --- .github/workflows/ci.yaml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..b733717 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + branches: [develop, main] + push: + branches: [develop, main] + +jobs: + build: + name: Build & Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build + run: yarn build + + - name: Lint + run: yarn lint + continue-on-error: true From 2f9fa3ed21b7b787e8dddae3fe27293a412af872 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 23:00:26 +0200 Subject: [PATCH 3/3] Drop the empty-string fallback on the Pro API key (#108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. 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. * Align bootstrap error message with helper throw * 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. * Make COINGECKO_API_KEY orthogonal: optional header, never a fallback 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 | 18 ++++++++++++------ README.md | 34 +++++++++++++++++++++++++++++++++- api.config.ts | 38 +++++++++++++++++++------------------- 3 files changed, 64 insertions(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 5138f26..eda9c6e 100644 --- a/.env.example +++ b/.env.example @@ -8,12 +8,18 @@ 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: 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 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 7a7c221..1381fc7 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 b04ff4b..d6f2a92 100644 --- a/api.config.ts +++ b/api.config.ts @@ -9,11 +9,13 @@ dotenv.config(); // Verify environment if (process.env.RPC_URL_MAINNET === undefined) throw new Error('RPC_URL_MAINNET not available'); if (process.env.RPC_URL_POLYGON === undefined) throw new Error('RPC_URL_POLYGON 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_API_KEY or COINGECKO_BASE_URL must be set'); +// 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'); } // Config type @@ -21,8 +23,8 @@ export type ConfigType = { app: string; indexer: string; indexerFallback: string; + coingeckoBaseUrl: string; coingeckoApiKey: string | undefined; - coingeckoBaseUrl: string | undefined; chain: Chain; network: { mainnet: string; @@ -48,8 +50,8 @@ export const CONFIG: ConfigType = { app: process.env.CONFIG_APP_URL || 'https://app.deuro.com', indexer: process.env.CONFIG_INDEXER_URL || 'https://ponder.deuro.com/', indexerFallback: process.env.CONFIG_INDEXER_FALLBACK_URL || 'https://dev.ponder.deuro.com/', + coingeckoBaseUrl: process.env.COINGECKO_BASE_URL, coingeckoApiKey: process.env.COINGECKO_API_KEY || undefined, - coingeckoBaseUrl: process.env.COINGECKO_BASE_URL || undefined, chain: process.env.CONFIG_CHAIN === 'polygon' ? polygon : mainnet, // @dev: default mainnet network: { mainnet: process.env.RPC_URL_MAINNET, @@ -120,20 +122,18 @@ 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. +// 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) => { - if (CONFIG.coingeckoBaseUrl) { - return fetch(`${CONFIG.coingeckoBaseUrl}${query}`); + const headers: Record = { accept: 'application/json' }; + if (CONFIG.coingeckoApiKey) { + headers['x-cg-pro-api-key'] = CONFIG.coingeckoApiKey; } - const uri: string = `https://pro-api.coingecko.com${query}`; - return fetch(uri, { - headers: { 'x-cg-pro-api-key': CONFIG.coingeckoApiKey ?? '' }, - }); + return fetch(`${CONFIG.coingeckoBaseUrl}${query}`, { headers }); }; // Contract addresses for the active chain