diff --git a/.env.example b/.env.example index ef5494b..2c3fc03 100644 --- a/.env.example +++ b/.env.example @@ -16,18 +16,17 @@ MAX_BLOCKS_PER_BATCH=1000 PRICE_CACHE_TTL_MS=120000 PG_MAX_CLIENTS=10 -# CoinGecko Configuration (optional) +# CoinGecko Configuration. # -# Three 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 -# 3. Anonymous: leave both empty — calls hit api.coingecko.com unauthenticated. -# COINGECKO_BASE_URL= +# 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) 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/monitoring.config.ts b/src/config/monitoring.config.ts index 1ed2df8..a0018d3 100644 --- a/src/config/monitoring.config.ts +++ b/src/config/monitoring.config.ts @@ -69,11 +69,11 @@ export class MonitoringConfig { @IsOptional() @IsString() - coingeckoApiKey?: string; + coingeckoBaseUrl?: string; @IsOptional() @IsString() - coingeckoBaseUrl?: string; + coingeckoApiKey?: string; @IsOptional() @IsString() @@ -103,8 +103,8 @@ 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.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 e26841f..b36096a 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) { + throw new Error('COINGECKO_BASE_URL is not set'); + } } async getTokenPricesInEur(addresses: string[]): Promise<{ [key: string]: string }> { @@ -202,27 +205,30 @@ export class PriceService { } /** - * Resolve which CoinGecko endpoint and authentication header to use. + * 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`). * - * Three 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. + * `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 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 headers: Record = { accept: 'application/json' }; const apiKey = this.appConfigService.coingeckoApiKey; if (apiKey) { headers['x-cg-pro-api-key'] = apiKey; - return { baseUrl: 'https://pro-api.coingecko.com', headers }; } - return { baseUrl: 'https://api.coingecko.com', headers }; + return { baseUrl, headers }; } private async fetchFxRates( @@ -297,20 +303,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`, {