From 78fce6af9bd5aa807437f006e6102bf6e3378f6c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 2 May 2026 23:05:26 +0200 Subject: [PATCH 1/3] Add Telegram alerts bot link to monitoring frontend footer (#62) Introduces a minimal footer with a "Get alerts" Telegram link. Bot URLs (PRD + DEV) live as code config in frontend/src/constants.ts. The DEV/PRD selection reads VITE_DEPLOYMENT_ENV with strict validation (throws on missing/invalid value, no fallback). The deployment env is baked into each image via Docker build-args: - frontend-dev.yaml (:beta on develop) -> VITE_DEPLOYMENT_ENV=dev - frontend-prd.yaml (:latest on main) -> VITE_DEPLOYMENT_ENV=prd --- .github/workflows/frontend-dev.yaml | 4 +++- .github/workflows/frontend-prd.yaml | 4 +++- frontend/Dockerfile | 2 ++ frontend/src/App.tsx | 6 +++-- frontend/src/components/Footer.tsx | 34 +++++++++++++++++++++++++++++ frontend/src/constants.ts | 14 ++++++++++++ frontend/src/vite-env.d.ts | 9 ++++++++ 7 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/Footer.tsx create mode 100644 frontend/src/constants.ts diff --git a/.github/workflows/frontend-dev.yaml b/.github/workflows/frontend-dev.yaml index 2b6653d..0fadda2 100644 --- a/.github/workflows/frontend-dev.yaml +++ b/.github/workflows/frontend-dev.yaml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v4 - name: Build Docker image - run: docker build -f frontend/Dockerfile -t ${{ env.DOCKER_TAGS }} . + run: docker build --build-arg VITE_DEPLOYMENT_ENV=dev -f frontend/Dockerfile -t ${{ env.DOCKER_TAGS }} . deploy: name: Deploy Frontend to DEV @@ -54,6 +54,8 @@ jobs: push: true tags: ${{ env.DOCKER_TAGS }} platforms: linux/arm64 + build-args: | + VITE_DEPLOYMENT_ENV=dev - name: Install cloudflared run: | diff --git a/.github/workflows/frontend-prd.yaml b/.github/workflows/frontend-prd.yaml index 678d00f..43cdd50 100644 --- a/.github/workflows/frontend-prd.yaml +++ b/.github/workflows/frontend-prd.yaml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v4 - name: Build Docker image - run: docker build -f frontend/Dockerfile -t ${{ env.DOCKER_TAGS }} . + run: docker build --build-arg VITE_DEPLOYMENT_ENV=prd -f frontend/Dockerfile -t ${{ env.DOCKER_TAGS }} . deploy: name: Deploy Frontend to PRD @@ -54,6 +54,8 @@ jobs: push: true tags: ${{ env.DOCKER_TAGS }} platforms: linux/arm64 + build-args: | + VITE_DEPLOYMENT_ENV=prd - name: Install cloudflared run: | diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8a0f869..1a41baf 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -6,6 +6,8 @@ COPY frontend/ . COPY shared/ /app/shared/ ARG VITE_API_BASE_URL=/api ENV VITE_API_BASE_URL=$VITE_API_BASE_URL +ARG VITE_DEPLOYMENT_ENV +ENV VITE_DEPLOYMENT_ENV=$VITE_DEPLOYMENT_ENV RUN npm run build FROM nginx:alpine diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b4e9087..607d2e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,13 +6,14 @@ import { ChallengesTable } from './components/ChallengesTable'; import { BridgesTable } from './components/BridgesTable'; import { MintersTable } from './components/MintersTable'; import { HealthStatus } from './components/HealthStatus'; +import { Footer } from './components/Footer'; function App() { const { health, deuro, positions, collateral, challenges, minters } = useApi(); return ( -
-
+
+
@@ -21,6 +22,7 @@ function App() {
+
); } diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx new file mode 100644 index 0000000..1c6abba --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -0,0 +1,34 @@ +import { TELEGRAM_BOT_URL } from '../constants'; + +export function Footer() { + return ( + + ); +} + +function TelegramIcon({ className = '' }: { className?: string }) { + return ( + + ); +} diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts new file mode 100644 index 0000000..8805b2b --- /dev/null +++ b/frontend/src/constants.ts @@ -0,0 +1,14 @@ +export type DeploymentEnv = 'prd' | 'dev'; + +const rawDeploymentEnv = import.meta.env.VITE_DEPLOYMENT_ENV; +if (rawDeploymentEnv !== 'prd' && rawDeploymentEnv !== 'dev') { + throw new Error(`VITE_DEPLOYMENT_ENV must be "prd" or "dev" (got: "${rawDeploymentEnv}")`); +} +export const DEPLOYMENT_ENV: DeploymentEnv = rawDeploymentEnv; + +export const TELEGRAM_BOT = { + prd: 'https://t.me/deuro_monitor_prd_bot', + dev: 'https://t.me/deuro_monitor_dev_bot', +} as const; + +export const TELEGRAM_BOT_URL = TELEGRAM_BOT[DEPLOYMENT_ENV]; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe..5565f7b 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL?: string; + readonly VITE_DEPLOYMENT_ENV?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} From 5860354ef22b3f816a0c0f6cb5566d961ff81bb3 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 21:07:43 +0200 Subject: [PATCH 2/3] Add CoinGecko proxy support and pricing health watchdogs (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add CoinGecko proxy support and pricing health watchdogs The endpoint switch for Pro keys was already correct here, but several adjacent gaps remained: - COINGECKO_BASE_URL env var lets the service be pointed at a caching pricing proxy that injects the upstream key itself, so individual consumers no longer have to hold the key. - @Cron hourly staleness watchdog: if USD/EUR + USD/CHF have not refreshed for 60 min, raise a critical Telegram alert. EUR-denominated price conversions silently drift on stale FX otherwise (re-arms every 6 h while the condition persists, clears on the next successful fetch). - @Cron daily quota probe of /api/v3/key: alert when the Pro account's monthly remaining call credit drops below 25k, so the account does not silently run dry. * Close two staleness/quota gaps in pricing watchdogs - fxLastSuccessMs now seeds with Date.now() at construction. With the earlier `null` default, a container that started while CoinGecko was down would never raise the staleness alert: the cron returned early on `null` and the gate could only open after a first success that wasn't arriving. - checkCoingeckoQuota now routes through resolveCoingeckoEndpoint(), so a proxy-mode deployment (no local API key, key held by the proxy) is still covered. The previous `if (!apiKey) return` silently skipped the daily probe in exactly the configuration this PR was meant to roll out, leaving no signal when the Pro account approaches exhaustion. * Drop the 1 h FX cache now that the proxy holds the upstream cache The 1 h FX_CACHE_TTL_MS predates the pricing-proxy: it existed to keep direct CoinGecko calls within the Pro quota when each consumer talked to the upstream itself. With the proxy fronting CoinGecko (60 s shared cache, request coalescing), keeping a separate 1 h local cache no longer earns its keep — it just pushes the FX refresh interval an order of magnitude beyond the staleness watchdog window, opening a clock-edge race at the hour boundary where checkFxStaleness can fire before the next cycle's fetch lands. FX now uses the same priceCacheTtlMs as every other price (default 2 min) so refreshes ride the regular monitoring cycle. Threshold stays at 60 min with a comfortable margin, and the proxy still absorbs the shared upstream pressure. * Apply prettier formatting after FX cache TTL change --- .env.example | 14 ++++ src/config/config.service.ts | 4 + src/config/monitoring.config.ts | 5 ++ src/monitoringV2/price.service.ts | 134 +++++++++++++++++++++++++++--- 4 files changed, 144 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 5c1f162..ef5494b 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,20 @@ MAX_BLOCKS_PER_BATCH=1000 PRICE_CACHE_TTL_MS=120000 PG_MAX_CLIENTS=10 +# CoinGecko Configuration (optional) +# +# 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_API_KEY= + # Telegram Bot Configuration (optional) # TELEGRAM_BOT_TOKEN=5123456789:ABCdefGHIjklMNOpqrsTUVwxyz # Path to the persisted subscribers file. Each operator subscribes themselves by diff --git a/src/config/config.service.ts b/src/config/config.service.ts index 7805649..72582bd 100644 --- a/src/config/config.service.ts +++ b/src/config/config.service.ts @@ -88,6 +88,10 @@ export class AppConfigService { return this.monitoringConfig.coingeckoApiKey || undefined; } + get coingeckoBaseUrl(): string | undefined { + return this.monitoringConfig.coingeckoBaseUrl || undefined; + } + get environment(): string | undefined { return this.monitoringConfig.environment; } diff --git a/src/config/monitoring.config.ts b/src/config/monitoring.config.ts index 92e2c77..1ed2df8 100644 --- a/src/config/monitoring.config.ts +++ b/src/config/monitoring.config.ts @@ -71,6 +71,10 @@ export class MonitoringConfig { @IsString() coingeckoApiKey?: string; + @IsOptional() + @IsString() + coingeckoBaseUrl?: 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.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 ddc6221..e26841f 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -1,9 +1,11 @@ import { EquityABI } from '@deuro/eurocoin'; import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import axios from 'axios'; import { ethers } from 'ethers'; import { ProviderService } from './provider.service'; import { AppConfigService } from 'src/config/config.service'; +import { TelegramService } from './telegram.service'; const nDEPS = '0xc71104001a3ccda1bef1177d765831bd1bfe8ee6'; const DEPS = '0x103747924e74708139a9400e4ab4bea79fffa380'; @@ -32,17 +34,41 @@ interface PriceCacheEntry { timestamp: number; } +interface CoingeckoEndpoint { + baseUrl: string; + headers: Record; +} + +interface CoingeckoKeyInfo { + plan?: string; + monthly_call_credit?: number; + current_total_monthly_calls?: number; + current_remaining_monthly_calls?: number; +} + +const STALENESS_ALERT_THRESHOLD_MS = 60 * 60 * 1000; +const STALENESS_ALERT_REPEAT_MS = 6 * 60 * 60 * 1000; +const QUOTA_REMAINING_ALERT_THRESHOLD = 25_000; +const QUOTA_ALERT_REPEAT_MS = 24 * 60 * 60 * 1000; + @Injectable() export class PriceService { - private static readonly FX_CACHE_TTL_MS = 3_600_000; // 1 hour — FX rates change slowly private readonly CACHE_TTL_MS: number; private readonly logger = new Logger(PriceService.name); private priceCache = new Map(); private pendingFxRates: Promise<{ eur: number; chf: number }> | null = null; + // Initialised to container-start time so the staleness watchdog still + // fires when the very first FX fetch never succeeds (CoinGecko down at + // boot, restart-loop). Without this, a `null` initial value would + // suppress the alert indefinitely. + private fxLastSuccessMs: number = Date.now(); + private fxStalenessAlertedAt: number | null = null; + private quotaAlertedAt: number | null = null; constructor( private readonly providerService: ProviderService, - private readonly appConfigService: AppConfigService + private readonly appConfigService: AppConfigService, + private readonly telegramService: TelegramService ) { this.CACHE_TTL_MS = this.appConfigService.priceCacheTtlMs; } @@ -160,12 +186,7 @@ export class PriceService { const chfCached = this.priceCache.get('usd-chf-rate'); const now = Date.now(); - if ( - eurCached && - chfCached && - now - eurCached.timestamp < PriceService.FX_CACHE_TTL_MS && - now - chfCached.timestamp < PriceService.FX_CACHE_TTL_MS - ) { + if (eurCached && chfCached && now - eurCached.timestamp < this.CACHE_TTL_MS && now - chfCached.timestamp < this.CACHE_TTL_MS) { return { eur: Number(eurCached.value), chf: Number(chfCached.value) }; } @@ -180,16 +201,36 @@ export class PriceService { } } + /** + * Resolve which CoinGecko endpoint and authentication header to use. + * + * 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. + */ + private resolveCoingeckoEndpoint(): CoingeckoEndpoint { + const headers: Record = { accept: 'application/json' }; + const explicitBase = this.appConfigService.coingeckoBaseUrl; + if (explicitBase) { + return { baseUrl: explicitBase, headers }; + } + 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 }; + } + private async fetchFxRates( eurCached: PriceCacheEntry | undefined, chfCached: PriceCacheEntry | undefined ): Promise<{ eur: number; chf: number }> { try { - const apiKey = this.appConfigService.coingeckoApiKey; - const headers: Record = { accept: 'application/json' }; - const baseUrl = apiKey ? 'https://pro-api.coingecko.com' : 'https://api.coingecko.com'; - if (apiKey) headers['x-cg-pro-api-key'] = apiKey; - + const { baseUrl, headers } = this.resolveCoingeckoEndpoint(); const response = await axios.get(`${baseUrl}/api/v3/simple/price?ids=usd&vs_currencies=eur,chf`, { headers, timeout: 10000, @@ -209,6 +250,8 @@ export class PriceService { const now = Date.now(); this.priceCache.set('usd-eur-rate', { value: String(eur), timestamp: now }); this.priceCache.set('usd-chf-rate', { value: String(chf), timestamp: now }); + this.fxLastSuccessMs = now; + this.fxStalenessAlertedAt = null; this.logger.debug(`FX rates: USD/EUR=${eur}, USD/CHF=${chf}`); return { eur, chf }; @@ -231,6 +274,71 @@ export class PriceService { return specialTokens.has(address.toLowerCase()); } + /** + * Hourly probe: when the last successful FX-rate fetch is older than + * STALENESS_ALERT_THRESHOLD_MS, USD/EUR and USD/CHF have decayed and any + * EUR-converted token price is operating on stale reference — escalate via + * Telegram. Self-deduplicates: re-alerts at most every + * STALENESS_ALERT_REPEAT_MS while the condition persists, and clears on the + * next successful fetch. + */ + @Cron(CronExpression.EVERY_HOUR) + async checkFxStaleness(): Promise { + const staleness = Date.now() - this.fxLastSuccessMs; + if (staleness < STALENESS_ALERT_THRESHOLD_MS) return; + if (this.fxStalenessAlertedAt && Date.now() - this.fxStalenessAlertedAt < STALENESS_ALERT_REPEAT_MS) return; + + this.fxStalenessAlertedAt = Date.now(); + const minutes = Math.round(staleness / 60_000); + await this.telegramService.sendCriticalAlert( + `USD/EUR + USD/CHF FX rates have not refreshed for ${minutes} min — ` + + `EUR-denominated price conversions are running on stale reference.` + ); + } + + /** + * 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. + */ + @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`, { + headers, + timeout: 10000, + }); + const { current_remaining_monthly_calls: remaining, monthly_call_credit: credit } = response.data; + if (typeof remaining !== 'number' || typeof credit !== 'number' || credit <= 0) return; + + const pct = Math.round((remaining / credit) * 100); + this.logger.log(`CoinGecko quota: ${remaining} of ${credit} calls remaining (${pct}%)`); + + if (remaining >= QUOTA_REMAINING_ALERT_THRESHOLD) { + this.quotaAlertedAt = null; + return; + } + if (this.quotaAlertedAt && Date.now() - this.quotaAlertedAt < QUOTA_ALERT_REPEAT_MS) return; + + this.quotaAlertedAt = Date.now(); + await this.telegramService.sendCriticalAlert( + `CoinGecko monthly quota almost exhausted: ${remaining.toLocaleString()} of ` + + `${credit.toLocaleString()} calls remaining (${pct}%).` + ); + } catch (error) { + this.logger.warn(`CoinGecko quota probe failed: ${error.message ?? error}`); + } + } + // Cache management methods private getFromCache(addresses: string[]): { [key: string]: string } { From 3ccdc84ae50d257db4120a1bebd6c6f3a2ff4b84 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 23:00:42 +0200 Subject: [PATCH 3/3] Refuse to start without a CoinGecko config (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. * 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. * Make COINGECKO_API_KEY orthogonal: optional header, never a fallback 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 | 21 +++++++------- README.md | 33 ++++++++++++++++++++++ src/config/monitoring.config.ts | 6 ++-- src/monitoringV2/price.service.ts | 46 +++++++++++++++---------------- 4 files changed, 68 insertions(+), 38 deletions(-) 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`, {