From dddc83f339c7fc0bf19d7f9f39a99117403dc361 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 21:27:47 +0200 Subject: [PATCH 1/3] Refuse to start without a CoinGecko config instead of falling back The resolver previously returned \`https://api.coingecko.com\` (anonymous public host) when neither COINGECKO_BASE_URL nor COINGECKO_API_KEY was set. That is a silent fallback and exactly the shape \"no fallbacks\" is meant to prevent: a misconfigured service would happily come up, route every call through the IP-shared anonymous quota, and surface only later as sporadic 429s. The constructor now throws when both env vars are empty, and the resolver no longer carries an unauthenticated branch. The only two remaining paths are the explicit proxy origin and direct Pro. --- .env.example | 7 ++++--- src/monitoringV2/price.service.ts | 13 ++++++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index ef5494b..280cce4 100644 --- a/.env.example +++ b/.env.example @@ -16,9 +16,11 @@ MAX_BLOCKS_PER_BATCH=1000 PRICE_CACHE_TTL_MS=120000 PG_MAX_CLIENTS=10 -# CoinGecko Configuration (optional) +# CoinGecko Configuration (one of the two is required — service refuses to +# start when neither is set; silent anonymous mode would mask the misconfig +# and route prices through the IP-shared quota that already proved fragile). # -# Three deployment modes, in priority order: +# Two deployment modes, in priority order: # 1. Caching pricing proxy: set COINGECKO_BASE_URL to the proxy origin, leave # COINGECKO_API_KEY empty. The proxy injects the upstream key itself. # COINGECKO_BASE_URL=http://pricing-proxy:8080/coingecko @@ -26,7 +28,6 @@ PG_MAX_CLIENTS=10 # COINGECKO_BASE_URL empty. Calls go to pro-api.coingecko.com with # `x-cg-pro-api-key`. # COINGECKO_API_KEY=CG-xxxxxxxxxxxxxxxxxxxxxxxx -# 3. Anonymous: leave both empty — calls hit api.coingecko.com unauthenticated. # COINGECKO_BASE_URL= # COINGECKO_API_KEY= diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index e26841f..3868920 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -71,6 +71,9 @@ export class PriceService { private readonly telegramService: TelegramService ) { this.CACHE_TTL_MS = this.appConfigService.priceCacheTtlMs; + if (!this.appConfigService.coingeckoBaseUrl && !this.appConfigService.coingeckoApiKey) { + throw new Error('CoinGecko is not configured: set COINGECKO_BASE_URL or COINGECKO_API_KEY'); + } } async getTokenPricesInEur(addresses: string[]): Promise<{ [key: string]: string }> { @@ -204,12 +207,16 @@ export class PriceService { /** * Resolve which CoinGecko endpoint and authentication header to use. * - * Three modes, in priority order: + * Two modes, in priority order: * 1. `COINGECKO_BASE_URL` set → trust the caller (typically a pricing proxy * that injects the upstream key itself); send no auth header. * 2. `COINGECKO_API_KEY` set → Pro tier: pro-api.coingecko.com with * `x-cg-pro-api-key`. - * 3. Otherwise → unauthenticated public endpoint. + * + * The constructor refuses to start the service when neither is set, so + * this method does not need to fall back to the anonymous public + * endpoint — silent anonymous mode would mask a misconfiguration and + * route prices through the IP-shared quota that already proved fragile. */ private resolveCoingeckoEndpoint(): CoingeckoEndpoint { const headers: Record = { accept: 'application/json' }; @@ -222,7 +229,7 @@ export class PriceService { headers['x-cg-pro-api-key'] = apiKey; return { baseUrl: 'https://pro-api.coingecko.com', headers }; } - return { baseUrl: 'https://api.coingecko.com', headers }; + throw new Error('CoinGecko is not configured: set COINGECKO_BASE_URL or COINGECKO_API_KEY'); } private async fetchFxRates( From 977a0e47eadab39db7c18267641d63f4847a8626 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 21:40:29 +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. The resolver now has a single path (`COINGECKO_BASE_URL` only) and the constructor refuses to start without it. No more two-branch logic, no more dead code. --- .env.example | 18 +++--------- src/config/config.service.ts | 6 +--- src/config/monitoring.config.ts | 5 ---- src/monitoringV2/price.service.ts | 48 +++++++++---------------------- 4 files changed, 18 insertions(+), 59 deletions(-) diff --git a/.env.example b/.env.example index 280cce4..d7ddb7a 100644 --- a/.env.example +++ b/.env.example @@ -16,20 +16,10 @@ MAX_BLOCKS_PER_BATCH=1000 PRICE_CACHE_TTL_MS=120000 PG_MAX_CLIENTS=10 -# CoinGecko Configuration (one of the two is required — service refuses to -# start when neither is set; silent anonymous mode would mask the misconfig -# and route prices through the IP-shared quota that already proved fragile). -# -# Two deployment modes, in priority order: -# 1. Caching pricing proxy: set COINGECKO_BASE_URL to the proxy origin, leave -# COINGECKO_API_KEY empty. The proxy injects the upstream key itself. -# COINGECKO_BASE_URL=http://pricing-proxy:8080/coingecko -# 2. Direct Pro tier: set COINGECKO_API_KEY to a Pro key, leave -# COINGECKO_BASE_URL empty. Calls go to pro-api.coingecko.com with -# `x-cg-pro-api-key`. -# COINGECKO_API_KEY=CG-xxxxxxxxxxxxxxxxxxxxxxxx -# COINGECKO_BASE_URL= -# COINGECKO_API_KEY= +# CoinGecko: the service always talks to the in-cluster pricing proxy, which +# holds the upstream key and validates upstream errors. Required; the service +# refuses to start without it. +COINGECKO_BASE_URL=http://pricing-proxy:8080/coingecko # Telegram Bot Configuration (optional) # TELEGRAM_BOT_TOKEN=5123456789:ABCdefGHIjklMNOpqrsTUVwxyz diff --git a/src/config/config.service.ts b/src/config/config.service.ts index 72582bd..ca2be96 100644 --- a/src/config/config.service.ts +++ b/src/config/config.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { MonitoringConfig } from './monitoring.config'; -const SENSITIVE_KEYS = new Set(['rpcUrl', 'databaseUrl', 'telegramBotToken', 'coingeckoApiKey']); +const SENSITIVE_KEYS = new Set(['rpcUrl', 'databaseUrl', 'telegramBotToken']); function redactConfig(config: T): T { return walkRedact(config, '') as T; @@ -84,10 +84,6 @@ export class AppConfigService { return this.monitoringConfig.alertTimeframeHours || 12; } - get coingeckoApiKey(): string | undefined { - return this.monitoringConfig.coingeckoApiKey || undefined; - } - get coingeckoBaseUrl(): string | undefined { return this.monitoringConfig.coingeckoBaseUrl || undefined; } diff --git a/src/config/monitoring.config.ts b/src/config/monitoring.config.ts index 1ed2df8..a1844d4 100644 --- a/src/config/monitoring.config.ts +++ b/src/config/monitoring.config.ts @@ -67,10 +67,6 @@ export class MonitoringConfig { @Min(1) alertTimeframeHours?: number; - @IsOptional() - @IsString() - coingeckoApiKey?: string; - @IsOptional() @IsString() coingeckoBaseUrl?: string; @@ -103,7 +99,6 @@ export default registerAs('monitoring', () => { config.telegramGroupsJson = process.env.TELEGRAM_GROUPS_JSON; config.telegramAlertsEnabled = (process.env.TELEGRAM_ALERTS_ENABLED || 'false').toLowerCase() === 'true'; config.alertTimeframeHours = parseInt(process.env.ALERT_TIMEFRAME_HOURS || '12'); - config.coingeckoApiKey = process.env.COINGECKO_API_KEY || ''; config.coingeckoBaseUrl = process.env.COINGECKO_BASE_URL || ''; config.environment = process.env.ENVIRONMENT?.toLowerCase(); config.chain = process.env.CHAIN; diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index 3868920..023579f 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -71,8 +71,8 @@ export class PriceService { private readonly telegramService: TelegramService ) { this.CACHE_TTL_MS = this.appConfigService.priceCacheTtlMs; - if (!this.appConfigService.coingeckoBaseUrl && !this.appConfigService.coingeckoApiKey) { - throw new Error('CoinGecko is not configured: set COINGECKO_BASE_URL or COINGECKO_API_KEY'); + if (!this.appConfigService.coingeckoBaseUrl) { + throw new Error('COINGECKO_BASE_URL is not set'); } } @@ -205,31 +205,17 @@ export class PriceService { } /** - * Resolve which CoinGecko endpoint and authentication header to use. - * - * Two modes, in priority order: - * 1. `COINGECKO_BASE_URL` set → trust the caller (typically a pricing proxy - * that injects the upstream key itself); send no auth header. - * 2. `COINGECKO_API_KEY` set → Pro tier: pro-api.coingecko.com with - * `x-cg-pro-api-key`. - * - * The constructor refuses to start the service when neither is set, so - * this method does not need to fall back to the anonymous public - * endpoint — silent anonymous mode would mask a misconfiguration and - * route prices through the IP-shared quota that already proved fragile. + * Resolve the CoinGecko endpoint. The service is always pointed at the + * in-cluster pricing proxy via `COINGECKO_BASE_URL`; the proxy holds the + * upstream key and validates upstream errors. The constructor refuses to + * start without that env var, so this method only has the one path. */ private resolveCoingeckoEndpoint(): CoingeckoEndpoint { - const headers: Record = { accept: 'application/json' }; - const explicitBase = this.appConfigService.coingeckoBaseUrl; - if (explicitBase) { - return { baseUrl: explicitBase, headers }; + const baseUrl = this.appConfigService.coingeckoBaseUrl; + if (!baseUrl) { + throw new Error('COINGECKO_BASE_URL is not set'); } - const apiKey = this.appConfigService.coingeckoApiKey; - if (apiKey) { - headers['x-cg-pro-api-key'] = apiKey; - return { baseUrl: 'https://pro-api.coingecko.com', headers }; - } - throw new Error('CoinGecko is not configured: set COINGECKO_BASE_URL or COINGECKO_API_KEY'); + return { baseUrl, headers: { accept: 'application/json' } }; } private async fetchFxRates( @@ -304,20 +290,12 @@ export class PriceService { } /** - * Daily probe of /api/v3/key. Emits a critical alert when the monthly - * remaining call credit drops below QUOTA_REMAINING_ALERT_THRESHOLD. - * - * Routes through the same endpoint resolution as price calls so a proxy - * deployment (key held only by the proxy) is still covered. Skipped only - * when the service runs fully anonymous (no proxy and no key) — in that - * case there is no Pro account to monitor. + * Daily probe of /api/v3/key through the pricing proxy. Emits a critical + * alert when the monthly remaining call credit drops below + * QUOTA_REMAINING_ALERT_THRESHOLD. */ @Cron(CronExpression.EVERY_DAY_AT_NOON) async checkCoingeckoQuota(): Promise { - const explicitBase = this.appConfigService.coingeckoBaseUrl; - const apiKey = this.appConfigService.coingeckoApiKey; - if (!explicitBase && !apiKey) return; - try { const { baseUrl, headers } = this.resolveCoingeckoEndpoint(); const response = await axios.get(`${baseUrl}/api/v3/key`, { From 3c18d99021953c54f5ef43b887727b7c9ffa0388 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 22:24:12 +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 Operators without a pricing-proxy can now point COINGECKO_BASE_URL at pro-api.coingecko.com directly and supply COINGECKO_API_KEY — 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 | 14 ++++++++++--- README.md | 33 +++++++++++++++++++++++++++++++ src/config/config.service.ts | 6 +++++- src/config/monitoring.config.ts | 5 +++++ src/monitoringV2/price.service.ts | 23 ++++++++++++++++----- 5 files changed, 72 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index d7ddb7a..2c3fc03 100644 --- a/.env.example +++ b/.env.example @@ -16,10 +16,18 @@ MAX_BLOCKS_PER_BATCH=1000 PRICE_CACHE_TTL_MS=120000 PG_MAX_CLIENTS=10 -# CoinGecko: the service always talks to the in-cluster pricing proxy, which -# holds the upstream key and validates upstream errors. Required; the service -# refuses to start without it. +# CoinGecko Configuration. +# +# COINGECKO_BASE_URL: required. The origin the service 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 Configuration (optional) # TELEGRAM_BOT_TOKEN=5123456789:ABCdefGHIjklMNOpqrsTUVwxyz diff --git a/README.md b/README.md index 548b83d..7c2f0d9 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ cp .env.example .env # - DATABASE_URL: PostgreSQL connection string # - RPC_URL: Ethereum mainnet RPC endpoint # - BLOCKCHAIN_ID: Must be 1 (Ethereum mainnet) +# - COINGECKO_BASE_URL: required, see "CoinGecko" section below # Generate Prisma client npm run prisma:generate @@ -67,6 +68,38 @@ docker rm -f deuro-test Swagger documentation available at: `http://localhost:3001/swagger` +## CoinGecko + +The monitoring service needs a CoinGecko-compatible endpoint for USD/EUR +and USD/CHF FX rates (drives EUR-denominated price conversions and the +staleness watchdog) and the daily Pro quota probe. Configuration is two +env vars: + +| Var | Required | Purpose | +|---|---|---| +| `COINGECKO_BASE_URL` | yes | Origin the service 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 you can talk to CoinGecko directly: + +```env +COINGECKO_BASE_URL=https://pro-api.coingecko.com +COINGECKO_API_KEY=CG-xxxxxxxxxxxxxxxxxxxxxxxx +``` + +The service refuses to start without `COINGECKO_BASE_URL`. + ## Deployment - **Development**: Push to `develop` branch → auto-deploys to `dev.monitoring.deuro.com` diff --git a/src/config/config.service.ts b/src/config/config.service.ts index ca2be96..72582bd 100644 --- a/src/config/config.service.ts +++ b/src/config/config.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { MonitoringConfig } from './monitoring.config'; -const SENSITIVE_KEYS = new Set(['rpcUrl', 'databaseUrl', 'telegramBotToken']); +const SENSITIVE_KEYS = new Set(['rpcUrl', 'databaseUrl', 'telegramBotToken', 'coingeckoApiKey']); function redactConfig(config: T): T { return walkRedact(config, '') as T; @@ -84,6 +84,10 @@ export class AppConfigService { return this.monitoringConfig.alertTimeframeHours || 12; } + get coingeckoApiKey(): string | undefined { + return this.monitoringConfig.coingeckoApiKey || undefined; + } + get coingeckoBaseUrl(): string | undefined { return this.monitoringConfig.coingeckoBaseUrl || undefined; } diff --git a/src/config/monitoring.config.ts b/src/config/monitoring.config.ts index a1844d4..a0018d3 100644 --- a/src/config/monitoring.config.ts +++ b/src/config/monitoring.config.ts @@ -71,6 +71,10 @@ export class MonitoringConfig { @IsString() coingeckoBaseUrl?: string; + @IsOptional() + @IsString() + coingeckoApiKey?: string; + @IsOptional() @IsString() environment?: string; @@ -100,6 +104,7 @@ export default registerAs('monitoring', () => { config.telegramAlertsEnabled = (process.env.TELEGRAM_ALERTS_ENABLED || 'false').toLowerCase() === 'true'; config.alertTimeframeHours = parseInt(process.env.ALERT_TIMEFRAME_HOURS || '12'); config.coingeckoBaseUrl = process.env.COINGECKO_BASE_URL || ''; + config.coingeckoApiKey = process.env.COINGECKO_API_KEY || ''; config.environment = process.env.ENVIRONMENT?.toLowerCase(); config.chain = process.env.CHAIN; diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index 023579f..b36096a 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -205,17 +205,30 @@ export class PriceService { } /** - * Resolve the CoinGecko endpoint. The service is always pointed at the - * in-cluster pricing proxy via `COINGECKO_BASE_URL`; the proxy holds the - * upstream key and validates upstream errors. The constructor refuses to - * start without that env var, so this method only has the one path. + * Resolve the CoinGecko endpoint. + * + * `COINGECKO_BASE_URL` is required and points at the origin the service + * talks to — typically the in-cluster pricing-proxy + * (https://github.com/DFXswiss/pricing-proxy), but any CoinGecko-compatible + * origin works (e.g. `https://pro-api.coingecko.com` or + * `https://api.coingecko.com`). + * + * `COINGECKO_API_KEY` is optional and is attached as the + * `x-cg-pro-api-key` header on every request when set. Leave it unset when + * talking to the pricing-proxy (the proxy injects its own key) or when + * hitting the public host anonymously. */ private resolveCoingeckoEndpoint(): CoingeckoEndpoint { const baseUrl = this.appConfigService.coingeckoBaseUrl; if (!baseUrl) { throw new Error('COINGECKO_BASE_URL is not set'); } - return { baseUrl, headers: { accept: 'application/json' } }; + const headers: Record = { accept: 'application/json' }; + const apiKey = this.appConfigService.coingeckoApiKey; + if (apiKey) { + headers['x-cg-pro-api-key'] = apiKey; + } + return { baseUrl, headers }; } private async fetchFxRates(