diff --git a/.env.example b/.env.example index 598e28c..bb7ff9e 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,19 @@ RPC_BATCH_SIZE=25 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 2b39740..4854337 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 53b4753..8327de0 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 db31b7e..ad7cdba 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ cp .env.example .env # - DATABASE_URL: PostgreSQL connection string # - RPC_URL: https://rpc.citreascan.com (Citrea mainnet) # - BLOCKCHAIN_ID: Must be 4114 (Citrea) +# - COINGECKO_BASE_URL: required, see "CoinGecko" section below # Generate Prisma client npm run prisma:generate @@ -87,6 +88,37 @@ Swagger documentation available at: `http://localhost:3001/swagger` | `/jusd` | JUSD supply and protocol stats | | `/minters` | Registered minters | +## CoinGecko + +The monitoring service needs a CoinGecko-compatible endpoint for BTC spot +prices (drives the WCBTC suspicious-liq-price 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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index fb119a4..1ebabdb 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 cb56bb1..4756a17 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, jusd, 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..f7208bd --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -0,0 +1,36 @@ +import { DEPLOYMENT_ENV, resolveChain, TELEGRAM_BOT } from '../constants'; + +export function Footer() { + const botUrl = TELEGRAM_BOT[resolveChain()][DEPLOYMENT_ENV]; + + 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..aca41c6 --- /dev/null +++ b/frontend/src/constants.ts @@ -0,0 +1,26 @@ +export type DeploymentEnv = 'prd' | 'dev'; +export type Chain = 'mainnet' | 'testnet'; + +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 = { + mainnet: { + prd: 'https://t.me/juicedollar_monitor_prd_bot', + dev: 'https://t.me/juicedollar_monitor_dev_bot', + }, + testnet: { + prd: 'https://t.me/juicedollar_monitor_tst_prd_bot', + dev: 'https://t.me/juicedollar_monitor_tst_dev_bot', + }, +} as const; + +export function resolveChain(): Chain { + if (typeof window === 'undefined') { + throw new Error('resolveChain() requires window.location'); + } + return window.location.hostname.includes('testnet') ? 'testnet' : 'mainnet'; +} 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 3dfa55b..b64a1f8 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 b4cae82..5bf4e91 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -1,10 +1,11 @@ import { EquityABI, ADDRESS } from '@juicedollar/jusd'; 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 { TokenRepository } from './prisma/repositories/token.repository'; +import { TelegramService } from './telegram.service'; interface TokenPrice { data: { @@ -23,18 +24,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 readonly CACHE_TTL_MS: number; private readonly logger = new Logger(PriceService.name); private priceCache = new Map(); private wcbtcAddresses = new Set(); + // Initialised to container-start time so the staleness watchdog still + // fires when the very first BTC fetch never succeeds (CoinGecko down at + // boot, restart-loop). Without this, a `null` initial value would suppress + // the alert indefinitely. + private btcLastSuccessMs: number = Date.now(); + private btcStalenessAlertedAt: 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'); + } } registerWcbtcAddress(address: string): void { @@ -50,9 +79,7 @@ export class PriceService { const equityAddresses = addresses.filter((addr) => addr.toLowerCase() === equityAddress); const wcbtcAddresses = addresses.filter((addr) => this.isWcbtc(addr)); - const standardAddresses = addresses.filter( - (addr) => addr.toLowerCase() !== equityAddress && !this.isWcbtc(addr) - ); + const standardAddresses = addresses.filter((addr) => addr.toLowerCase() !== equityAddress && !this.isWcbtc(addr)); const [equityPrices, wcbtcPrices, geckoTerminalPrices] = await Promise.all([ this.getEquityPrice(equityAddresses), @@ -82,6 +109,33 @@ export class PriceService { return { ...cached, ...prices }; } + /** + * 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 getBtcPriceInUsd(): Promise { const cached = this.priceCache.get('btc-usd'); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) { @@ -89,17 +143,17 @@ export class PriceService { } try { - const apiKey = this.appConfigService.coingeckoApiKey; - const headers: Record = { accept: 'application/json' }; - if (apiKey) headers['x-cg-demo-api-key'] = apiKey; - - const response = await axios.get('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd', { + const { baseUrl, headers } = this.resolveCoingeckoEndpoint(); + const response = await axios.get(`${baseUrl}/api/v3/simple/price?ids=bitcoin&vs_currencies=usd`, { headers, timeout: 10000, }); const price = String(response.data.bitcoin.usd); - this.priceCache.set('btc-usd', { value: price, timestamp: Date.now() }); + const now = Date.now(); + this.priceCache.set('btc-usd', { value: price, timestamp: now }); + this.btcLastSuccessMs = now; + this.btcStalenessAlertedAt = null; this.logger.log(`BTC price: $${price}`); return price; } catch (error) { @@ -108,6 +162,62 @@ export class PriceService { } } + /** + * Hourly probe: when the last successful BTC fetch is older than + * STALENESS_ALERT_THRESHOLD_MS, the suspicious-liq-price trigger for WCBTC + * collateral is running on stale (or missing) spot — 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 checkBtcStaleness(): Promise { + const staleness = Date.now() - this.btcLastSuccessMs; + if (staleness < STALENESS_ALERT_THRESHOLD_MS) return; + if (this.btcStalenessAlertedAt && Date.now() - this.btcStalenessAlertedAt < STALENESS_ALERT_REPEAT_MS) return; + + this.btcStalenessAlertedAt = Date.now(); + const minutes = Math.round(staleness / 60_000); + await this.telegramService.sendCriticalAlert( + `BTC spot has not refreshed for ${minutes} min — suspicious-liq-price trigger ` + + `for WCBTC positions is running on stale or missing 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}`); + } + } + private async getGeckoTerminalPricesInUSD(addresses: string[]): Promise<{ [key: string]: string }> { if (addresses.length === 0) return {}; diff --git a/src/monitoringV2/prisma/repositories/challenge.repository.ts b/src/monitoringV2/prisma/repositories/challenge.repository.ts index 2d62578..48bcdf6 100644 --- a/src/monitoringV2/prisma/repositories/challenge.repository.ts +++ b/src/monitoringV2/prisma/repositories/challenge.repository.ts @@ -88,15 +88,18 @@ export class ChallengeRepository { challenger_address: string; position_address: string; start_timestamp: bigint; - size: any; - current_price: any; + size: string; + current_price: string; t24_alerted: boolean; t2_alerted: boolean; challenge_period: bigint | null; }> >` SELECT c.challenge_id, c.hub_address, c.challenger_address, c.position_address, - c.start_timestamp, c.size, c.current_price, c.t24_alerted, c.t2_alerted, + c.start_timestamp, + c.size::text as size, + c.current_price::text as current_price, + c.t24_alerted, c.t2_alerted, p.challenge_period FROM challenge_states c LEFT JOIN position_states p ON p.address = c.position_address @@ -127,8 +130,8 @@ export class ChallengeRepository { challengerAddress: r.challenger_address, positionAddress: r.position_address, startTimestamp: BigInt(r.start_timestamp), - size: BigInt(r.size.toString()), - currentPrice: BigInt(r.current_price.toString()), + size: BigInt(r.size), + currentPrice: BigInt(r.current_price), t24Alerted: r.t24_alerted, t2Alerted: r.t2_alerted, challengePeriod: BigInt(r.challenge_period),