feat(wallet-monitor): list every wallet with full address in each alert

When an alert fires, the Slack message now includes the full roster — low (🔴)
and healthy (🟢) wallets alike — each with its complete, copy-pasteable address
(backticked), instead of only the low wallets with a truncated address. The
posting trigger and dedup/reminder logic are unchanged: still silent when all
wallets are healthy; the buyer (normally healthy) now appears for context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-06-16 11:28:43 +02:00
parent 06e7cd8800
commit ac6689275f
2 changed files with 43 additions and 18 deletions

View File

@@ -5,7 +5,9 @@ One-shot wallet-balance Slack alerter for the `vector-marketplace` mainnet box.
Checks every wallet in `wallets.json` against a threshold (default **10 APEX**), 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 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 `reminder_hours` while a wallet stays low, and posts a recovery message when it
climbs back above. Balances are read from Ogmios via 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
`ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind `ReadOnlyOgmiosProvider` (`@marketplace/shared/chain`) — the same client behind
the buyer's `/v1/wallet/balance` and `buyer/scripts/monitor-wallets.ts`. the buyer's `/v1/wallet/balance` and `buyer/scripts/monitor-wallets.ts`.

View File

@@ -50,10 +50,6 @@ function fmtAp3x(lovelace: bigint): string {
return (Number(lovelace) / LOVELACE_PER_AP3X).toFixed(2); return (Number(lovelace) / LOVELACE_PER_AP3X).toFixed(2);
} }
function shortAddr(a: string): string {
return a.length <= 18 ? a : `${a.slice(0, 10)}…${a.slice(-6)}`;
}
function loadState(path: string): StateFile { function loadState(path: string): StateFile {
try { try {
const obj: unknown = JSON.parse(readFileSync(path, "utf-8")); const obj: unknown = JSON.parse(readFileSync(path, "utf-8"));
@@ -123,8 +119,13 @@ async function main(): Promise<void> {
const now = Date.now(); const now = Date.now();
const nextState: StateFile = { ...prevState }; const nextState: StateFile = { ...prevState };
const lowLines: string[] = []; // Classify each successfully-queried wallet, advancing dedup state. The
const recoveredLines: string[] = []; // 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;
let anyRecovered = false;
const recoveredNames = new Set<string>();
for (const c of checked) { for (const c of checked) {
// Skip wallets we couldn't query — leave their state untouched so we don't // Skip wallets we couldn't query — leave their state untouched so we don't
@@ -138,9 +139,7 @@ async function main(): Promise<void> {
const wasLow = prev?.status === "low"; const wasLow = prev?.status === "low";
const due = !wasLow || now - (prev?.lastAlertMs ?? 0) >= reminderMs; const due = !wasLow || now - (prev?.lastAlertMs ?? 0) >= reminderMs;
if (due) { if (due) {
lowLines.push( anyDueLow = true;
`🔴 ${c.name} ${fmtAp3x(c.lovelace)} / ${fmtAp3x(c.minLovelace)} APEX ${shortAddr(c.address)}`,
);
nextState[c.name] = { status: "low", lastAlertMs: now }; nextState[c.name] = { status: "low", lastAlertMs: now };
} else { } else {
// Still low but the reminder window hasn't elapsed — keep prev as-is. // Still low but the reminder window hasn't elapsed — keep prev as-is.
@@ -148,20 +147,42 @@ async function main(): Promise<void> {
} }
} else { } else {
if (prev?.status === "low") { if (prev?.status === "low") {
recoveredLines.push(`✅ ${c.name} ${fmtAp3x(c.lovelace)} APEX (recovered)`); anyRecovered = true;
recoveredNames.add(c.name);
} }
nextState[c.name] = { status: "ok", lastAlertMs: prev?.lastAlertMs ?? 0 }; nextState[c.name] = { status: "ok", lastAlertMs: prev?.lastAlertMs ?? 0 };
} }
} }
const needPost = lowLines.length > 0 || recoveredLines.length > 0; const needPost = anyDueLow || anyRecovered;
if (needPost) { if (needPost) {
const title = const anyLow = checked.some(
lowLines.length > 0 (c) => c.error === null && c.lovelace !== null && c.lovelace < c.minLovelace,
? "⚠️ *Wallet balance alert* — vector-marketplace" );
: "✅ *Wallet balance recovered* — vector-marketplace"; const title = anyLow
const text = [title, ...lowLines, ...recoveredLines].join("\n"); ? "⚠️ *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); const r = await postSlack(cfg.slackWebhookUrl, text);
if (!r.ok) { if (!r.ok) {
@@ -170,7 +191,9 @@ async function main(): Promise<void> {
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
console.log(`[wallet-monitor] posted: ${lowLines.length} low, ${recoveredLines.length} recovered`); console.log(
`[wallet-monitor] posted roster: ${low.length} low, ${ok.length} ok, ${failed.length} failed`,
);
} else { } else {
console.log("[wallet-monitor] all wallets healthy — no Slack post"); console.log("[wallet-monitor] all wallets healthy — no Slack post");
} }