From 5c386bd35e6d2671ee52959f1e3d0a2917e6ba07 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 14:44:29 +0200 Subject: [PATCH 1/4] 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. --- .env.example | 14 ++++ src/config/config.service.ts | 4 + src/config/monitoring.config.ts | 5 ++ src/monitoringV2/price.service.ts | 117 ++++++++++++++++++++++++++++-- 4 files changed, 134 insertions(+), 6 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..123b002 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,6 +34,23 @@ 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 @@ -39,10 +58,14 @@ export class PriceService { private readonly logger = new Logger(PriceService.name); private priceCache = new Map(); private pendingFxRates: Promise<{ eur: number; chf: number }> | null = null; + private fxLastSuccessMs: number | null = null; + 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; } @@ -180,16 +203,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 +252,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 +276,66 @@ 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 { + if (this.fxLastSuccessMs === null) return; + 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. + * Skipped when no Pro key is configured here (proxy-mode or anonymous). + */ + @Cron(CronExpression.EVERY_DAY_AT_NOON) + async checkCoingeckoQuota(): Promise { + const apiKey = this.appConfigService.coingeckoApiKey; + if (!apiKey) return; + + try { + const response = await axios.get('https://pro-api.coingecko.com/api/v3/key', { + headers: { accept: 'application/json', 'x-cg-pro-api-key': apiKey }, + 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 9863e8ae1898f0d1027e239e9e50b02c01668328 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 14:54:51 +0200 Subject: [PATCH 2/4] 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. --- src/monitoringV2/price.service.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index 123b002..3a5e531 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -58,7 +58,11 @@ export class PriceService { private readonly logger = new Logger(PriceService.name); private priceCache = new Map(); private pendingFxRates: Promise<{ eur: number; chf: number }> | null = null; - private fxLastSuccessMs: 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; @@ -286,7 +290,6 @@ export class PriceService { */ @Cron(CronExpression.EVERY_HOUR) async checkFxStaleness(): Promise { - if (this.fxLastSuccessMs === null) return; 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; @@ -302,16 +305,22 @@ 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. - * Skipped when no Pro key is configured here (proxy-mode or anonymous). + * + * 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 (!apiKey) return; + if (!explicitBase && !apiKey) return; try { - const response = await axios.get('https://pro-api.coingecko.com/api/v3/key', { - headers: { accept: 'application/json', 'x-cg-pro-api-key': apiKey }, + 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; From 944cb036947803a24f2c1c0ab718decd36101c6b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 15:08:04 +0200 Subject: [PATCH 3/4] Drop the 1 h FX cache now that the proxy holds the upstream cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/monitoringV2/price.service.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index 3a5e531..634140e 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -53,7 +53,6 @@ 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(); @@ -190,8 +189,8 @@ export class PriceService { if ( eurCached && chfCached && - now - eurCached.timestamp < PriceService.FX_CACHE_TTL_MS && - now - chfCached.timestamp < PriceService.FX_CACHE_TTL_MS + now - eurCached.timestamp < this.CACHE_TTL_MS && + now - chfCached.timestamp < this.CACHE_TTL_MS ) { return { eur: Number(eurCached.value), chf: Number(chfCached.value) }; } From 596dda242d67db5c5db2a994f0d7f093eafe9064 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sun, 10 May 2026 15:12:28 +0200 Subject: [PATCH 4/4] Apply prettier formatting after FX cache TTL change --- src/monitoringV2/price.service.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index 634140e..e26841f 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -186,12 +186,7 @@ export class PriceService { const chfCached = this.priceCache.get('usd-chf-rate'); const now = Date.now(); - if ( - eurCached && - chfCached && - now - eurCached.timestamp < this.CACHE_TTL_MS && - now - chfCached.timestamp < this.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) }; }