Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/config/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions src/config/monitoring.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export class MonitoringConfig {
@IsString()
coingeckoApiKey?: string;

@IsOptional()
@IsString()
coingeckoBaseUrl?: string;

@IsOptional()
@IsString()
environment?: string;
Expand Down Expand Up @@ -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;

Expand Down
134 changes: 121 additions & 13 deletions src/monitoringV2/price.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,17 +34,41 @@ interface PriceCacheEntry {
timestamp: number;
}

interface CoingeckoEndpoint {
baseUrl: string;
headers: Record<string, string>;
}

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<string, PriceCacheEntry>();
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;
}
Expand Down Expand Up @@ -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) };
}

Expand All @@ -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<string, string> = { 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<string, string> = { 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,
Expand All @@ -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 };
Expand All @@ -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<void> {
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<void> {
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<CoingeckoKeyInfo>(`${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 } {
Expand Down
Loading