From 1598ac88b1736a44d2e61c46655d787f678f3f12 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Thu, 7 May 2026 17:33:45 +0200
Subject: [PATCH 1/3] Add Telegram alerts bot link to monitoring frontend
footer (#29)
Introduces a minimal footer with a "Get alerts" Telegram link. Bot URLs
(mainnet/testnet x prd/dev = 4 bots) live as code config in
frontend/src/constants.ts. The chain is resolved at runtime from
window.location.hostname (testnet domains -> testnet, else mainnet).
The prd/dev split is baked into each image at build time via Docker
build-args, with strict validation on VITE_DEPLOYMENT_ENV (no fallback).
- frontend-dev.yaml (:beta on develop) -> VITE_DEPLOYMENT_ENV=dev
- frontend-prd.yaml (:latest on main) -> VITE_DEPLOYMENT_ENV=prd
---
.github/workflows/frontend-dev.yaml | 4 +++-
.github/workflows/frontend-prd.yaml | 4 +++-
frontend/Dockerfile | 2 ++
frontend/src/App.tsx | 6 +++--
frontend/src/components/Footer.tsx | 36 +++++++++++++++++++++++++++++
frontend/src/constants.ts | 26 +++++++++++++++++++++
frontend/src/vite-env.d.ts | 9 ++++++++
7 files changed, 83 insertions(+), 4 deletions(-)
create mode 100644 frontend/src/components/Footer.tsx
create mode 100644 frontend/src/constants.ts
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/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;
+}
From 8ba7c0e182721163cdb6c8ab5dd260fe88451de1 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Sun, 10 May 2026 23:07:30 +0200
Subject: [PATCH 2/3] Route CoinGecko via Pro endpoint with proxy support and
add health watchdogs (#31)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Route CoinGecko via Pro endpoint with proxy support and add health watchdogs
The price service was sending Pro keys to api.coingecko.com with the
demo header, so CoinGecko counted the calls against the anonymous
IP-shared quota and routinely returned 429 (~47% of BTC fetches per day).
WCBTC suspicious-liq-price detection silently degraded whenever the BTC
spot cache went stale.
- COINGECKO_BASE_URL env var lets a service be pointed at a caching
pricing proxy that injects the upstream key itself; otherwise a Pro
key in COINGECKO_API_KEY now correctly routes to pro-api.coingecko.com
with x-cg-pro-api-key.
- @Cron hourly staleness watchdog: if BTC spot has not refreshed for
60 min, raise a critical Telegram alert (re-arms every 6 h while the
condition persists, clears on the next successful fetch).
- @Cron daily quota probe of /api/v3/key: alert when the Pro account's
monthly remaining call credit drops below 25k, so the account does
not silently run dry.
* Close two staleness/quota gaps in pricing watchdogs
- btcLastSuccessMs now seeds with Date.now() at construction. With the
earlier `null` default, a container that started while CoinGecko was
down would never raise the staleness alert: the cron returned early on
`null` and the gate could only open after a first success that wasn't
arriving.
- checkCoingeckoQuota now routes through resolveCoingeckoEndpoint(), so
a proxy-mode deployment (no local API key, key held by the proxy) is
still covered. The previous `if (!apiKey) return` silently skipped
the daily probe in exactly the configuration this PR was meant to roll
out, leaving no signal when the Pro account approaches exhaustion.
- Single Date.now() per success path on the BTC fetch so the cache
timestamp and the staleness anchor stay aligned.
* Refuse to start without a CoinGecko config instead of falling back
The resolver previously returned \`https://api.coingecko.com\`
(anonymous public host) when neither COINGECKO_BASE_URL nor
COINGECKO_API_KEY was set. That is a silent fallback and exactly the
shape \"no fallbacks\" is meant to prevent: a misconfigured service
would happily come up, route every call through the IP-shared
anonymous quota, and surface only later as sporadic 429s.
The constructor now throws when both env vars are empty, and the
resolver no longer carries an unauthenticated branch. The only two
remaining paths are the explicit proxy origin and direct Pro.
* Drop the direct-Pro code path; everything goes via pricing-proxy
COINGECKO_API_KEY is no longer read by this service. The proxy stack
holds the upstream key. The resolver now has a single path
(`COINGECKO_BASE_URL` only) and the constructor refuses to start
without it. No more two-branch logic, no more dead code.
* Make COINGECKO_API_KEY orthogonal: optional header, never a fallback
Operators without a pricing-proxy can now point COINGECKO_BASE_URL at
pro-api.coingecko.com directly and supply COINGECKO_API_KEY — the key
is attached as the x-cg-pro-api-key header on every request when set,
no two-branch logic, no fallback. In the DFX setup COINGECKO_API_KEY
stays unset because the proxy injects its own key.
README documents the pattern and links the proxy reference
implementation at github.com/DFXswiss/pricing-proxy.
---
.env.example | 13 +++
README.md | 32 ++++++++
src/config/config.service.ts | 4 +
src/config/monitoring.config.ts | 5 ++
src/monitoringV2/price.service.ts | 132 +++++++++++++++++++++++++++---
5 files changed, 175 insertions(+), 11 deletions(-)
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/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/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 {};
From a238817189122c3065f09534a02290bb720c8638 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Mon, 11 May 2026 00:24:21 +0200
Subject: [PATCH 3/3] fix(monitoringV2): cast NUMERIC columns to text in
challenge raw query (#32)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`findActiveWithChallengePeriod` selects `size` and `current_price` from
`challenge_states`, both NUMERIC(78,0). Prisma's `$queryRaw` was
materialising the values as JS `number`, which loses precision for
values beyond 2^53 and serialises them via `.toString()` in scientific
notation (e.g. `2.8391...e+22`). `BigInt()` does not accept that
format, so the monitoring cycle crashed with
"Cannot convert to a BigInt" on mainnet — JDM has
challenges over 28391.7 JUSD (28.4e21 wei), JDT does not, which is why
only JDM was affected.
Fix mirrors the pattern already used in `position.repository.ts`:
explicit `::text` casts in the SQL, typed as `string` in the row
shape, then `BigInt(r.size)` directly without an intermediate
`.toString()`.
---
.../prisma/repositories/challenge.repository.ts | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
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),