From d85161f1419da418fa3f3127aa0b275237374104 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 20 May 2026 14:10:25 +0200 Subject: [PATCH] Active BTC heartbeat to stop chain-outage false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkBtcStaleness` alerts when `btcLastSuccessMs` is older than 1h. The field was only advanced by `getBtcPriceInUsd`, which is only reached when the monitoring cycle prices a WCBTC position. When the chain stops producing blocks (Citrea outage 2026-05-19 07:40–09:30 UTC, ~115 min), the cycle has no new positions to price → the on-demand path never runs → the hourly probe at 09:00 UTC fires a CRITICAL Telegram alert for a problem entirely off the BTC-spot path. This adds an active heartbeat that fetches the BTC spot every 5 minutes regardless of chain activity. The watchdog now only fires when CoinGecko or the pricing-proxy is actually unreachable. `getBtcPriceInUsd` and the new heartbeat share `fetchAndCacheBtcPrice`, which does the upstream call, populates the cache, advances `btcLastSuccessMs`, and clears the alert dedup timer on success. Five-minute cadence matches the existing `MonitoringService` cron interval, well inside `STALENESS_ALERT_THRESHOLD_MS = 60 min`. Each heartbeat hits the in-cluster pricing-proxy with its 60 s cache so the upstream cost is at most ~12 CoinGecko-Pro calls/hour per monitor container (still subject to the existing PRICE_CACHE_TTL_MS). --- src/monitoringV2/price.service.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index b4963a0..7b68aca 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -135,6 +135,17 @@ export class PriceService { return cached.value; } + const fresh = await this.fetchAndCacheBtcPrice(); + if (fresh !== null) return fresh; + return cached?.value ?? null; + } + + /** + * Unconditional fetch + cache + bookkeeping. Used both by the on-demand + * `getBtcPriceInUsd` (after cache miss) and the active heartbeat below. + * Returns null on upstream failure; never throws. + */ + private async fetchAndCacheBtcPrice(): Promise { try { const { baseUrl, headers } = this.resolveCoingeckoEndpoint(); const response = await axios.get(`${baseUrl}/api/v3/simple/price?ids=bitcoin&vs_currencies=usd`, { @@ -151,10 +162,25 @@ export class PriceService { return price; } catch (error) { this.logger.error(`Failed to fetch BTC price: ${error.message}`); - return cached?.value ?? null; + return null; } } + /** + * Active BTC heartbeat — fires every 5 minutes regardless of chain + * activity. Without this, `btcLastSuccessMs` only advances when the + * monitoring cycle pulls a WCBTC position price; if the chain stops + * producing blocks (Citrea outage), the on-demand path never runs and + * the hourly staleness watchdog below fires a false positive for a + * problem that is entirely off the BTC-spot path. The heartbeat + * decouples the watchdog from the block tick: it now only alerts when + * CoinGecko/the pricing-proxy is actually unreachable. + */ + @Cron(CronExpression.EVERY_5_MINUTES) + async refreshBtcPriceHeartbeat(): Promise { + await this.fetchAndCacheBtcPrice(); + } + /** * Hourly probe: when the last successful BTC fetch is older than * STALENESS_ALERT_THRESHOLD_MS, the suspicious-liq-price trigger for WCBTC