Files
agents-marketplace/wallet-monitor/src/index.ts
david b10706cf69 fix(wallet-monitor): post only on state changes, drop reminder re-alerts
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 <noreply@anthropic.com>
2026-07-28 11:12:45 +02:00

215 lines
7.7 KiB
TypeScript

/**
* wallet-monitor/src/index.ts — one-shot wallet-balance Slack alerter.
*
* 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
* 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.
*/
/* eslint-disable no-console */
import { readFileSync, writeFileSync, mkdirSync, renameSync } from "fs";
import { dirname } from "path";
import { ReadOnlyOgmiosProvider } from "@marketplace/shared/chain";
import { loadConfig, parseWalletsFile, type WalletEntry } from "./config.js";
import { postSlack } from "./slack.js";
const LOVELACE_PER_AP3X = 1_000_000;
type Status = "ok" | "low";
interface WalletState {
status: Status;
/** Epoch ms of the last alert we successfully posted for this wallet. */
lastAlertMs: number;
}
type StateFile = Record<string, WalletState>;
interface CheckedWallet {
name: string;
address: string;
minLovelace: bigint;
/** null when the balance query failed. */
lovelace: bigint | null;
error: string | null;
}
// ── helpers ───────────────────────────────────────────────────────────────
function ap3xToLovelace(ap3x: number): bigint {
return BigInt(Math.round(ap3x * LOVELACE_PER_AP3X));
}
function fmtAp3x(lovelace: bigint): string {
return (Number(lovelace) / LOVELACE_PER_AP3X).toFixed(2);
}
function loadState(path: string): StateFile {
try {
const obj: unknown = JSON.parse(readFileSync(path, "utf-8"));
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
return obj as StateFile;
}
return {};
} catch {
// Missing / unreadable / malformed state → start clean.
return {};
}
}
function saveState(path: string, state: StateFile): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
renameSync(tmp, path); // atomic replace
}
// ── main ────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const cfg = loadConfig(process.env);
// TEST mode: confirm the webhook works, then exit.
if (cfg.testMode) {
const r = await postSlack(
cfg.slackWebhookUrl,
"✅ *wallet-monitor configured* — test message from the vector-marketplace box.",
);
if (!r.ok) {
console.error(`[wallet-monitor] test Slack post failed: ${r.error}`);
process.exitCode = 1;
return;
}
console.log("[wallet-monitor] test Slack post ok");
return;
}
const walletsFile = parseWalletsFile(
JSON.parse(readFileSync(cfg.walletsPath, "utf-8")) as unknown,
);
const defaultMinAp3x = cfg.defaultMinAp3xOverride ?? walletsFile.defaultMinAp3x;
const provider = new ReadOnlyOgmiosProvider({ ogmiosUrl: cfg.ogmiosUrl });
const checked: CheckedWallet[] = await Promise.all(
walletsFile.wallets.map(async (w: WalletEntry): Promise<CheckedWallet> => {
const minLovelace = ap3xToLovelace(w.minAp3x ?? defaultMinAp3x);
try {
const utxos = await provider.queryUtxosByAddress(w.address);
const lovelace = utxos.reduce((a, u) => a + BigInt(u.lovelace), 0n);
return { name: w.name, address: w.address, minLovelace, lovelace, error: null };
} catch (e) {
const error = e instanceof Error ? e.message : String(e);
console.error(`[wallet-monitor] query failed for ${w.name} (${w.address}): ${error}`);
return { name: w.name, address: w.address, minLovelace, lovelace: null, error };
}
}),
);
const anyQueryFailed = checked.some((c) => c.error !== null);
const prevState = loadState(cfg.statePath);
const now = Date.now();
const nextState: StateFile = { ...prevState };
// 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<string>();
for (const c of checked) {
// Skip wallets we couldn't query — leave their state untouched so we don't
// mistake an Ogmios blip for a recovery.
if (c.error !== null || c.lovelace === null) continue;
const prev = prevState[c.name];
const isLow = c.lovelace < c.minLovelace;
if (isLow) {
if (prev?.status === "low") {
// Still low — already alerted when it dropped; stay silent.
nextState[c.name] = prev;
} else {
anyNewlyLow = true;
nextState[c.name] = { status: "low", lastAlertMs: now };
}
} else {
if (prev?.status === "low") {
anyRecovered = true;
recoveredNames.add(c.name);
}
nextState[c.name] = { status: "ok", lastAlertMs: prev?.lastAlertMs ?? 0 };
}
}
const needPost = anyNewlyLow || anyRecovered;
if (needPost) {
const anyLow = checked.some(
(c) => c.error === null && c.lovelace !== null && c.lovelace < c.minLovelace,
);
const title = anyLow
? "⚠️ *Wallet balance alert* — vector-marketplace"
: "✅ *Wallet balance recovered* — vector-marketplace";
// Full roster — every monitored wallet (low first, then healthy, then any
// that failed to query), each with its FULL address backticked so it's
// copy-pasteable in Slack.
const low: string[] = [];
const ok: string[] = [];
const failed: string[] = [];
for (const c of checked) {
if (c.error !== null || c.lovelace === null) {
failed.push(`⚠️ ${c.name} query failed\n \`${c.address}\``);
} else if (c.lovelace < c.minLovelace) {
low.push(
`🔴 ${c.name} ${fmtAp3x(c.lovelace)} / ${fmtAp3x(c.minLovelace)} APEX\n \`${c.address}\``,
);
} else {
const tag = recoveredNames.has(c.name) ? " (recovered)" : "";
ok.push(`🟢 ${c.name} ${fmtAp3x(c.lovelace)} APEX${tag}\n \`${c.address}\``);
}
}
const text = [title, ...low, ...ok, ...failed].join("\n");
const r = await postSlack(cfg.slackWebhookUrl, text);
if (!r.ok) {
// Leave state untouched so the alert retries next run.
console.error(`[wallet-monitor] Slack post failed (state unchanged): ${r.error}`);
process.exitCode = 1;
return;
}
console.log(
`[wallet-monitor] posted roster: ${low.length} low, ${ok.length} ok, ${failed.length} failed`,
);
} else {
const lowCount = checked.filter(
(c) => c.error === null && c.lovelace !== null && c.lovelace < c.minLovelace,
).length;
console.log(
lowCount > 0
? `[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 + alert stamps).
// Only reached when there was nothing to post or the post succeeded.
saveState(cfg.statePath, nextState);
if (anyQueryFailed) process.exitCode = 1;
}
main().catch((e) => {
console.error("[wallet-monitor] fatal:", e instanceof Error ? e.stack ?? e.message : e);
process.exit(1);
});