diff --git a/.env.example b/.env.example index 5c1f162..2c3fc03 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,19 @@ MAX_BLOCKS_PER_BATCH=1000 PRICE_CACHE_TTL_MS=120000 PG_MAX_CLIENTS=10 +# 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 # Path to the persisted subscribers file. Each operator subscribes themselves by 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/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/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; +} 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..a0018d3 100644 --- a/src/config/monitoring.config.ts +++ b/src/config/monitoring.config.ts @@ -67,6 +67,10 @@ export class MonitoringConfig { @Min(1) alertTimeframeHours?: number; + @IsOptional() + @IsString() + coingeckoBaseUrl?: string; + @IsOptional() @IsString() coingeckoApiKey?: string; @@ -99,6 +103,7 @@ 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.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 ddc6221..b36096a 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,19 +34,46 @@ 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; + if (!this.appConfigService.coingeckoBaseUrl) { + throw new Error('COINGECKO_BASE_URL is not set'); + } } async getTokenPricesInEur(addresses: string[]): Promise<{ [key: string]: string }> { @@ -160,12 +189,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 +204,39 @@ export class PriceService { } } + /** + * 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'); + } + 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( 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 +256,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 +280,63 @@ 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 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 { + 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 } {