feat(wallet-monitor): Slack alerts for low operator wallet balances
A one-shot monitor for the vector-marketplace box: checks each wallet in wallets.json against a threshold (default 10 APEX) via Ogmios (ReadOnlyOgmiosProvider) and posts a combined Slack message when any wallet is below it. Re-reminds every 6h while a wallet stays low and posts a recovery message when it climbs back above, deduped through a persisted state file. Runs hourly under host cron via `docker compose run --rm` — no long-running server, no ports/healthcheck. Reuses the buyer/supplier tsx Docker pattern and the existing balance-query client; addresses come from a static, gitignored wallets.json so the monitor never reads a private key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
wallet-monitor/.env.example
Normal file
19
wallet-monitor/.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# wallet-monitor/.env — copy to wallet-monitor/.env and fill in.
|
||||
# (.env is gitignored; only this .env.example template is committed.)
|
||||
|
||||
# REQUIRED — Slack Incoming Webhook URL alerts are POSTed to.
|
||||
# Create one at https://api.slack.com/messaging/webhooks
|
||||
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXXX/YYYY/ZZZZ
|
||||
|
||||
# OPTIONAL — Ogmios HTTP endpoint used to read balances.
|
||||
# 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.
|
||||
# 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
|
||||
# STATE_PATH=/repo/wallet-monitor/data/state.json
|
||||
70
wallet-monitor/Dockerfile
Normal file
70
wallet-monitor/Dockerfile
Normal file
@@ -0,0 +1,70 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# wallet-monitor/Dockerfile — image for the one-shot wallet-balance alerter.
|
||||
#
|
||||
# Same multi-stage tsx strategy as supplier/Dockerfile: install workspace deps
|
||||
# in a builder stage, ship source + node_modules to a slim runtime stage, run
|
||||
# the entry point with tsx (we do NOT compile ahead-of-time because
|
||||
# @marketplace/shared exports .ts via package.json#exports — see supplier/Dockerfile).
|
||||
#
|
||||
# This is NOT a long-running server: there is no HEALTHCHECK / EXPOSE. It's
|
||||
# invoked one-shot by cron: `docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml run --rm wallet-monitor`.
|
||||
#
|
||||
# Build context MUST be the repo root so pnpm can resolve workspace deps:
|
||||
# docker build -f wallet-monitor/Dockerfile -t marketplace-wallet-monitor .
|
||||
|
||||
ARG NODE_VERSION=20.19.0
|
||||
|
||||
# ─── builder ──────────────────────────────────────────────────────────────
|
||||
FROM node:${NODE_VERSION}-alpine AS builder
|
||||
WORKDIR /repo
|
||||
|
||||
# pnpm via corepack — lockfile pins pnpm@10.33.2.
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
# Workspace manifests first to maximise layer cache reuse. pnpm-lock.yaml indexes
|
||||
# the whole workspace, so every package's manifest must be present even though
|
||||
# --filter only installs what wallet-monitor needs.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY wallet-monitor/package.json ./wallet-monitor/package.json
|
||||
COPY buyer/package.json ./buyer/package.json
|
||||
COPY supplier/package.json ./supplier/package.json
|
||||
COPY indexer/package.json ./indexer/package.json
|
||||
|
||||
# Install only what wallet-monitor needs (+ its transitive workspace deps, i.e.
|
||||
# @marketplace/shared and the chain stack).
|
||||
RUN pnpm install --frozen-lockfile --filter '@marketplace/wallet-monitor...'
|
||||
|
||||
# libsodium-wrappers-sumo patch — @marketplace/shared/chain re-exports
|
||||
# LiveOgmiosProvider, which pulls @lucid-evolution/lucid → CML → the broken
|
||||
# relative `import "./libsodium-sumo.mjs"`. Copy the file into the wrapper's
|
||||
# expected path. Identical to supplier/Dockerfile. ARCHITECTURE.md §9 #16.
|
||||
RUN set -e \
|
||||
&& SRC=$(find node_modules/.pnpm -name "libsodium-sumo.mjs" -path "*/libsodium-sumo/dist/*" 2>/dev/null | head -1) \
|
||||
&& DST=$(find node_modules/.pnpm -type d -path "*libsodium-wrappers-sumo@*/node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm" 2>/dev/null | head -1) \
|
||||
&& [ -n "$SRC" ] && [ -n "$DST" ] || (echo "FATAL: libsodium-sumo paths not resolved" && exit 1) \
|
||||
&& cp "$SRC" "$DST/" \
|
||||
&& echo "patched libsodium wrapper: $DST/libsodium-sumo.mjs"
|
||||
|
||||
# Copy the actual source — shared + wallet-monitor.
|
||||
COPY packages/shared ./packages/shared
|
||||
COPY wallet-monitor ./wallet-monitor
|
||||
|
||||
# ─── runtime ──────────────────────────────────────────────────────────────
|
||||
FROM node:${NODE_VERSION}-alpine AS runtime
|
||||
WORKDIR /repo
|
||||
|
||||
# tini for signal-forwarding (graceful SIGTERM → process.exit).
|
||||
RUN apk add --no-cache tini
|
||||
|
||||
COPY --from=builder /repo /repo
|
||||
|
||||
# Run as root (the default for this image): the one-shot writes its dedup state
|
||||
# to a bind-mounted host dir (/repo/wallet-monitor/data), which is typically
|
||||
# root-owned on the box. No ports, no privileges to drop for a server.
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# tsx is hoisted to /repo/node_modules/.bin by pnpm — invoke via the workspace bin.
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["node_modules/.bin/tsx", "wallet-monitor/src/index.ts"]
|
||||
49
wallet-monitor/README.md
Normal file
49
wallet-monitor/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# wallet-monitor
|
||||
|
||||
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. 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`.
|
||||
|
||||
It is **not** a long-running server: it runs once and exits, invoked hourly by
|
||||
cron through `docker compose run --rm`. Dedup state lives in
|
||||
`wallet-monitor/data/state.json` (bind-mounted, gitignored).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `src/index.ts` | one-shot check + classify + Slack post + state |
|
||||
| `src/config.ts` | env loading + `wallets.json` parsing/validation |
|
||||
| `src/slack.ts` | `postSlack()` Incoming-Webhook helper |
|
||||
| `wallets.json` | the wallet list + thresholds (gitignored; see `wallets.example.json`) |
|
||||
| `.env` | `SLACK_WEBHOOK_URL` (gitignored; see `.env.example`) |
|
||||
|
||||
## Deploy on the box (one-time)
|
||||
|
||||
1. **Generate `wallets.json`.** From a laptop with SSH access, run
|
||||
`pnpm tsx buyer/scripts/monitor-wallets.ts --json` and copy the `address` of
|
||||
each of the 7 `*@vector-marketplace` rows into `wallet-monitor/wallets.json`
|
||||
(start from `wallets.example.json`). Per-wallet `min_ap3x` is optional.
|
||||
2. **Set the webhook.** `cp wallet-monitor/.env.example wallet-monitor/.env` and
|
||||
fill in `SLACK_WEBHOOK_URL`.
|
||||
3. **Build.** `docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml build`
|
||||
4. **Smoke-test the webhook.**
|
||||
`docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml run --rm -e TEST=1 wallet-monitor`
|
||||
→ expect a "✅ wallet-monitor configured" message in Slack.
|
||||
5. **Schedule it.** Add `/etc/cron.d/wallet-monitor`:
|
||||
```cron
|
||||
0 * * * * root cd /root/agents-marketplace && docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml run --rm wallet-monitor >> /var/log/wallet-monitor.log 2>&1
|
||||
```
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- **Failures don't alert.** If Ogmios is unreachable or a wallet query errors,
|
||||
the run logs to stderr and exits non-zero — no Slack noise — and that wallet's
|
||||
state is left untouched (an Ogmios blip is never mistaken for a recovery).
|
||||
- **State only advances on a successful post.** A failed Slack POST leaves the
|
||||
state file unchanged so the alert retries on the next run.
|
||||
16
wallet-monitor/package.json
Normal file
16
wallet-monitor/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@marketplace/wallet-monitor",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marketplace/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
156
wallet-monitor/src/config.ts
Normal file
156
wallet-monitor/src/config.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* wallet-monitor/src/config.ts — env + wallets-file loading.
|
||||
*
|
||||
* Env vars:
|
||||
* SLACK_WEBHOOK_URL — (required) Slack Incoming Webhook URL to post alerts to.
|
||||
* OGMIOS_URL — Ogmios HTTP endpoint. Default mainnet public endpoint.
|
||||
* WALLETS_PATH — path to wallets.json. Default /repo/wallet-monitor/wallets.json.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const DEFAULT_OGMIOS_URL = "https://ogmios.vector.mainnet.apexfusion.org";
|
||||
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;
|
||||
ogmiosUrl: string;
|
||||
walletsPath: string;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface WalletEntry {
|
||||
name: string;
|
||||
address: string;
|
||||
/** Per-wallet threshold override (APEX). Falls back to the file default. */
|
||||
minAp3x?: number;
|
||||
}
|
||||
|
||||
export interface WalletsFile {
|
||||
defaultMinAp3x: number;
|
||||
reminderHours: number;
|
||||
wallets: WalletEntry[];
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
const v = env[name];
|
||||
if (v === undefined || v === null || v === "") {
|
||||
throw new Error(`loadConfig: missing required env var ${name}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function nonEmpty(env: Record<string, string | undefined>, name: string, fallback: string): string {
|
||||
const v = env[name];
|
||||
return v !== undefined && v !== "" ? v : fallback;
|
||||
}
|
||||
|
||||
function parseOptionalPositiveNumber(
|
||||
raw: string | undefined,
|
||||
name: string,
|
||||
): number | null {
|
||||
if (raw === undefined || raw === "") return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
throw new Error(`loadConfig: ${name} must be a positive number, got "${raw}"`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined>): EnvConfig {
|
||||
const slackWebhookUrl = requireField(env, "SLACK_WEBHOOK_URL");
|
||||
return {
|
||||
slackWebhookUrl,
|
||||
ogmiosUrl: nonEmpty(env, "OGMIOS_URL", DEFAULT_OGMIOS_URL),
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
// ── wallets.json parsing ─────────────────────────────────────────────────
|
||||
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function parsePositiveNumberField(raw: unknown, label: string, fallback: number): number {
|
||||
if (raw === undefined) return fallback;
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
|
||||
throw new Error(`wallets.json: ${label} must be a positive number`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalise the parsed wallets.json. Pure (takes already-parsed
|
||||
* JSON) so it's easy to unit-test. Snake_case JSON keys → camelCase fields.
|
||||
*/
|
||||
export function parseWalletsFile(raw: unknown): WalletsFile {
|
||||
if (!isObject(raw)) {
|
||||
throw new Error("wallets.json: top-level value must be an object");
|
||||
}
|
||||
const defaultMinAp3x = parsePositiveNumberField(
|
||||
raw.default_min_ap3x,
|
||||
"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) {
|
||||
throw new Error("wallets.json: 'wallets' must be a non-empty array");
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const wallets: WalletEntry[] = walletsRaw.map((w, i) => {
|
||||
if (!isObject(w)) {
|
||||
throw new Error(`wallets.json: wallets[${i}] must be an object`);
|
||||
}
|
||||
const name = w.name;
|
||||
const address = w.address;
|
||||
if (typeof name !== "string" || name === "") {
|
||||
throw new Error(`wallets.json: wallets[${i}].name must be a non-empty string`);
|
||||
}
|
||||
if (typeof address !== "string" || !address.startsWith("addr")) {
|
||||
throw new Error(`wallets.json: wallets[${i}].address must be a bech32 address (addr…)`);
|
||||
}
|
||||
if (seen.has(name)) {
|
||||
throw new Error(`wallets.json: duplicate wallet name "${name}"`);
|
||||
}
|
||||
seen.add(name);
|
||||
|
||||
const entry: WalletEntry = { name, address };
|
||||
if (w.min_ap3x !== undefined) {
|
||||
if (typeof w.min_ap3x !== "number" || !Number.isFinite(w.min_ap3x) || w.min_ap3x <= 0) {
|
||||
throw new Error(`wallets.json: wallets[${i}].min_ap3x must be a positive number`);
|
||||
}
|
||||
entry.minAp3x = w.min_ap3x;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
|
||||
return { defaultMinAp3x, reminderHours, wallets };
|
||||
}
|
||||
188
wallet-monitor/src/index.ts
Normal file
188
wallet-monitor/src/index.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* 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;
|
||||
const MS_PER_HOUR = 3_600_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 shortAddr(a: string): string {
|
||||
return a.length <= 18 ? a : `${a.slice(0, 10)}…${a.slice(-6)}`;
|
||||
}
|
||||
|
||||
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 reminderMs = (cfg.reminderHoursOverride ?? walletsFile.reminderHours) * MS_PER_HOUR;
|
||||
|
||||
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 };
|
||||
|
||||
const lowLines: string[] = [];
|
||||
const recoveredLines: 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) {
|
||||
const wasLow = prev?.status === "low";
|
||||
const due = !wasLow || now - (prev?.lastAlertMs ?? 0) >= reminderMs;
|
||||
if (due) {
|
||||
lowLines.push(
|
||||
`🔴 ${c.name} ${fmtAp3x(c.lovelace)} / ${fmtAp3x(c.minLovelace)} APEX ${shortAddr(c.address)}`,
|
||||
);
|
||||
nextState[c.name] = { status: "low", lastAlertMs: now };
|
||||
} else {
|
||||
// Still low but the reminder window hasn't elapsed — keep prev as-is.
|
||||
nextState[c.name] = prev ?? { status: "low", lastAlertMs: now };
|
||||
}
|
||||
} else {
|
||||
if (prev?.status === "low") {
|
||||
recoveredLines.push(`✅ ${c.name} ${fmtAp3x(c.lovelace)} APEX (recovered)`);
|
||||
}
|
||||
nextState[c.name] = { status: "ok", lastAlertMs: prev?.lastAlertMs ?? 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const needPost = lowLines.length > 0 || recoveredLines.length > 0;
|
||||
|
||||
if (needPost) {
|
||||
const title =
|
||||
lowLines.length > 0
|
||||
? "⚠️ *Wallet balance alert* — vector-marketplace"
|
||||
: "✅ *Wallet balance recovered* — vector-marketplace";
|
||||
const text = [title, ...lowLines, ...recoveredLines].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: ${lowLines.length} low, ${recoveredLines.length} recovered`);
|
||||
} else {
|
||||
console.log("[wallet-monitor] all wallets healthy — no Slack post");
|
||||
}
|
||||
|
||||
// Persist the new state (records ok/low statuses + advanced reminder 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);
|
||||
});
|
||||
42
wallet-monitor/src/slack.ts
Normal file
42
wallet-monitor/src/slack.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* wallet-monitor/src/slack.ts — post a message to a Slack Incoming Webhook.
|
||||
*
|
||||
* Never throws: returns { ok, error } so the caller can decide whether to
|
||||
* advance the dedup state. A failed post leaves state untouched so the alert
|
||||
* retries on the next run.
|
||||
*/
|
||||
|
||||
export interface SlackResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const TIMEOUT_MS = 15_000;
|
||||
|
||||
export async function postSlack(
|
||||
webhookUrl: string,
|
||||
text: string,
|
||||
fetchImpl: typeof globalThis.fetch = globalThis.fetch,
|
||||
): Promise<SlackResult> {
|
||||
try {
|
||||
const init: RequestInit = {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text }),
|
||||
};
|
||||
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
|
||||
init.signal = AbortSignal.timeout(TIMEOUT_MS);
|
||||
}
|
||||
const res = await fetchImpl(webhookUrl, init);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
error: `Slack HTTP ${res.status} ${res.statusText}${body ? ` — ${body.slice(0, 200)}` : ""}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
8
wallet-monitor/tsconfig.json
Normal file
8
wallet-monitor/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
13
wallet-monitor/wallets.example.json
Normal file
13
wallet-monitor/wallets.example.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"default_min_ap3x": 10,
|
||||
"reminder_hours": 6,
|
||||
"wallets": [
|
||||
{ "name": "buyer", "address": "addr1_REPLACE_ME" },
|
||||
{ "name": "supplier-ollama", "address": "addr1_REPLACE_ME" },
|
||||
{ "name": "supplier-gpt", "address": "addr1_REPLACE_ME" },
|
||||
{ "name": "supplier-deepseek", "address": "addr1_REPLACE_ME" },
|
||||
{ "name": "supplier-kimi", "address": "addr1_REPLACE_ME" },
|
||||
{ "name": "supplier-kimi-chat","address": "addr1_REPLACE_ME" },
|
||||
{ "name": "supplier-tts", "address": "addr1_REPLACE_ME", "min_ap3x": 8 }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user