+
+
@@ -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),