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 } {