From b10706cf6900eb8a77a0e5effcb30fecbb348638 Mon Sep 17 00:00:00 2001 From: david Date: Tue, 28 Jul 2026 11:12:29 +0200 Subject: [PATCH] fix(wallet-monitor): post only on state changes, drop reminder re-alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack posts now fire only when a wallet newly drops below its threshold or recovers above it; a wallet that stays low never re-posts. The reminder_hours mechanism is removed entirely — per-wallet reminder clocks drifted apart (Ogmios blips skip wallets from a run), so with many chronically-low wallets some clock came due nearly every run, collapsing the 6h cadence to the hourly cron interval. Co-Authored-By: Claude Fable 5 --- wallet-monitor/.env.example | 5 ++-- wallet-monitor/README.md | 12 +++++----- wallet-monitor/src/config.ts | 19 ++++----------- wallet-monitor/src/index.ts | 36 +++++++++++++---------------- wallet-monitor/wallets.example.json | 1 - 5 files changed, 28 insertions(+), 45 deletions(-) diff --git a/wallet-monitor/.env.example b/wallet-monitor/.env.example index c80710f..e6bf042 100644 --- a/wallet-monitor/.env.example +++ b/wallet-monitor/.env.example @@ -9,10 +9,9 @@ SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXX/YYYY/ZZZZ # Defaults to the mainnet public endpoint (also pinned in the compose file). # OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org -# OPTIONAL — override the wallets.json default threshold / reminder cadence -# without editing the mounted wallets file. +# OPTIONAL — override the wallets.json default threshold without editing the +# mounted wallets file. # DEFAULT_MIN_AP3X=10 -# REMINDER_HOURS=6 # OPTIONAL — paths inside the container (defaults shown; bind-mounted by compose). # WALLETS_PATH=/repo/wallet-monitor/wallets.json diff --git a/wallet-monitor/README.md b/wallet-monitor/README.md index f585855..5efe1bc 100644 --- a/wallet-monitor/README.md +++ b/wallet-monitor/README.md @@ -2,12 +2,12 @@ One-shot wallet-balance Slack alerter for the `vector-marketplace` mainnet box. -Checks every wallet in `wallets.json` against a threshold (default **10 APEX**), -posts a combined Slack message when any wallet is below it, re-reminds every -`reminder_hours` while a wallet stays low, and posts a recovery message when it -climbs back above. Each alert lists **every** monitored wallet — low (🔴) and -healthy (🟢) — with its full, copy-pasteable address; it stays fully silent -when all wallets are healthy. Balances are read from Ogmios via +Checks every wallet in `wallets.json` against a threshold (default **10 APEX**) +and posts a combined Slack message only when something **changed** since the +last run — a wallet newly dropped below its threshold, or climbed back above +it. A wallet that merely stays low never re-posts. Each alert lists **every** +monitored wallet — low (🔴) and healthy (🟢) — with its full, copy-pasteable +address; it stays fully silent while nothing changes. Balances are read from Ogmios via `ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind the buyer's `/v1/wallet/balance` and `buyer/scripts/monitor-wallets.ts`. diff --git a/wallet-monitor/src/config.ts b/wallet-monitor/src/config.ts index 489084c..8f0422f 100644 --- a/wallet-monitor/src/config.ts +++ b/wallet-monitor/src/config.ts @@ -8,12 +8,11 @@ * STATE_PATH — path to the dedup state file (read/written each run). * Default /repo/wallet-monitor/data/state.json. * DEFAULT_MIN_AP3X — (optional) overrides wallets.json default_min_ap3x. - * REMINDER_HOURS — (optional) overrides wallets.json reminder_hours. * TEST — "1" posts a one-line "configured" message to Slack and exits. * - * wallets.json owns the wallet list + the default threshold / reminder cadence; - * the env overrides exist only so an operator can retune without editing the - * mounted wallets file. + * wallets.json owns the wallet list + the default threshold; the env override + * exists only so an operator can retune without editing the mounted wallets + * file. */ const DEFAULT_OGMIOS_URL = "https://ogmios.vector.mainnet.apexfusion.org"; @@ -21,7 +20,6 @@ const DEFAULT_WALLETS_PATH = "/repo/wallet-monitor/wallets.json"; const DEFAULT_STATE_PATH = "/repo/wallet-monitor/data/state.json"; const FALLBACK_DEFAULT_MIN_AP3X = 10; -const FALLBACK_REMINDER_HOURS = 6; export interface EnvConfig { slackWebhookUrl: string; @@ -30,8 +28,6 @@ export interface EnvConfig { statePath: string; /** When set, overrides the wallets-file default_min_ap3x. */ defaultMinAp3xOverride: number | null; - /** When set, overrides the wallets-file reminder_hours. */ - reminderHoursOverride: number | null; testMode: boolean; } @@ -44,7 +40,6 @@ export interface WalletEntry { export interface WalletsFile { defaultMinAp3x: number; - reminderHours: number; wallets: WalletEntry[]; } @@ -81,7 +76,6 @@ export function loadConfig(env: Record): EnvConfig { walletsPath: nonEmpty(env, "WALLETS_PATH", DEFAULT_WALLETS_PATH), statePath: nonEmpty(env, "STATE_PATH", DEFAULT_STATE_PATH), defaultMinAp3xOverride: parseOptionalPositiveNumber(env.DEFAULT_MIN_AP3X, "DEFAULT_MIN_AP3X"), - reminderHoursOverride: parseOptionalPositiveNumber(env.REMINDER_HOURS, "REMINDER_HOURS"), testMode: env.TEST === "1", }; } @@ -113,11 +107,6 @@ export function parseWalletsFile(raw: unknown): WalletsFile { "default_min_ap3x", FALLBACK_DEFAULT_MIN_AP3X, ); - const reminderHours = parsePositiveNumberField( - raw.reminder_hours, - "reminder_hours", - FALLBACK_REMINDER_HOURS, - ); const walletsRaw = raw.wallets; if (!Array.isArray(walletsRaw) || walletsRaw.length === 0) { @@ -152,5 +141,5 @@ export function parseWalletsFile(raw: unknown): WalletsFile { return entry; }); - return { defaultMinAp3x, reminderHours, wallets }; + return { defaultMinAp3x, wallets }; } diff --git a/wallet-monitor/src/index.ts b/wallet-monitor/src/index.ts index 5dd8566..1068b31 100644 --- a/wallet-monitor/src/index.ts +++ b/wallet-monitor/src/index.ts @@ -3,9 +3,9 @@ * * Runs once and exits (invoked hourly by cron via `docker compose run --rm`). * For each wallet in wallets.json: query its on-chain balance via Ogmios, and - * if it's below threshold post a combined Slack alert. A persisted state file - * dedups so a still-low wallet only re-alerts every `reminder_hours`, and a - * recovery message fires when a wallet climbs back above its threshold. + * post a combined Slack alert ONLY when something changed since the last run — + * a wallet newly dropped below threshold, or climbed back above it. A wallet + * that merely stays low never re-posts (state persisted across runs). * * Reuses ReadOnlyOgmiosProvider from @marketplace/shared/chain — the same * client behind the buyer's /v1/wallet/balance and buyer/scripts/monitor-wallets.ts. @@ -19,7 +19,6 @@ import { loadConfig, parseWalletsFile, type WalletEntry } from "./config.js"; import { postSlack } from "./slack.js"; const LOVELACE_PER_AP3X = 1_000_000; -const MS_PER_HOUR = 3_600_000; type Status = "ok" | "low"; @@ -94,7 +93,6 @@ async function main(): Promise { JSON.parse(readFileSync(cfg.walletsPath, "utf-8")) as unknown, ); const defaultMinAp3x = cfg.defaultMinAp3xOverride ?? walletsFile.defaultMinAp3x; - const reminderMs = (cfg.reminderHoursOverride ?? walletsFile.reminderHours) * MS_PER_HOUR; const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl }); @@ -119,11 +117,11 @@ async function main(): Promise { const now = Date.now(); const nextState: StateFile = { ...prevState }; - // Classify each successfully-queried wallet, advancing dedup state. The - // decision to POST is driven only by transitions (a newly-low wallet, a low - // wallet past its reminder window, or a recovery). But when we DO post, the - // message lists EVERY monitored wallet for full context (built below). - let anyDueLow = false; + // Classify each successfully-queried wallet. The decision to POST is driven + // only by state CHANGES (a wallet newly dropping low, or recovering) — a + // wallet that stays low never re-posts. But when we DO post, the message + // lists EVERY monitored wallet for full context (built below). + let anyNewlyLow = false; let anyRecovered = false; const recoveredNames = new Set(); @@ -136,14 +134,12 @@ async function main(): Promise { const isLow = c.lovelace < c.minLovelace; if (isLow) { - const wasLow = prev?.status === "low"; - const due = !wasLow || now - (prev?.lastAlertMs ?? 0) >= reminderMs; - if (due) { - anyDueLow = true; - nextState[c.name] = { status: "low", lastAlertMs: now }; + if (prev?.status === "low") { + // Still low — already alerted when it dropped; stay silent. + nextState[c.name] = prev; } else { - // Still low but the reminder window hasn't elapsed — keep prev as-is. - nextState[c.name] = prev ?? { status: "low", lastAlertMs: now }; + anyNewlyLow = true; + nextState[c.name] = { status: "low", lastAlertMs: now }; } } else { if (prev?.status === "low") { @@ -154,7 +150,7 @@ async function main(): Promise { } } - const needPost = anyDueLow || anyRecovered; + const needPost = anyNewlyLow || anyRecovered; if (needPost) { const anyLow = checked.some( @@ -200,12 +196,12 @@ async function main(): Promise { ).length; console.log( lowCount > 0 - ? `[wallet-monitor] ${lowCount} wallet(s) low but already alerted within the reminder window — no Slack post` + ? `[wallet-monitor] ${lowCount} wallet(s) still low (already alerted) — no change, no Slack post` : "[wallet-monitor] all wallets healthy — no Slack post", ); } - // Persist the new state (records ok/low statuses + advanced reminder stamps). + // Persist the new state (records ok/low statuses + alert stamps). // Only reached when there was nothing to post or the post succeeded. saveState(cfg.statePath, nextState); diff --git a/wallet-monitor/wallets.example.json b/wallet-monitor/wallets.example.json index 0c4496b..da2bea3 100644 --- a/wallet-monitor/wallets.example.json +++ b/wallet-monitor/wallets.example.json @@ -1,6 +1,5 @@ { "default_min_ap3x": 10, - "reminder_hours": 6, "wallets": [ { "name": "buyer", "address": "addr1_REPLACE_ME" }, { "name": "supplier-ollama", "address": "addr1_REPLACE_ME" },