diff --git a/.gitignore b/.gitignore index 060aac8..2a4faed 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,13 @@ coverage !.env.*.example # Local data / caches (matches at any depth — covers -# deploy/simulation/data/, indexer/data/, etc.) +# deploy/simulation/data/, indexer/data/, wallet-monitor/data/, etc.) data/ *.log + +# wallet-monitor: the real wallet list holds production mainnet addresses — +# keep it out of git; only wallets.example.json is committed. +wallet-monitor/wallets.json .vitest-cache .DS_Store diff --git a/deploy/mainnet/docker-compose.wallet-monitor.yml b/deploy/mainnet/docker-compose.wallet-monitor.yml new file mode 100644 index 0000000..5417107 --- /dev/null +++ b/deploy/mainnet/docker-compose.wallet-monitor.yml @@ -0,0 +1,40 @@ +# deploy/mainnet/docker-compose.wallet-monitor.yml — Wallet-balance Slack alerter — MAINNET +# +# A ONE-SHOT, not a long-running server: it checks every wallet listed in +# wallet-monitor/wallets.json, posts a Slack alert for any below threshold, +# then exits. There is no port, healthcheck, or restart policy. +# +# Invoked hourly by cron (see /etc/cron.d/wallet-monitor): +# cd /root/agents-marketplace && \ +# docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml run --rm wallet-monitor +# +# One-time setup on the box: +# 1) populate wallet-monitor/wallets.json (the 7 addresses — see the package README) +# 2) create wallet-monitor/.env with SLACK_WEBHOOK_URL=... +# 3) docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml build +# 4) smoke test: docker compose -f ... run --rm -e TEST=1 wallet-monitor + +name: marketplace-mainnet-wallet-monitor + +services: + wallet-monitor: + build: + context: ../.. + dockerfile: wallet-monitor/Dockerfile + env_file: + - path: ../../wallet-monitor/.env + required: false + environment: + # Mainnet-only service; pin the public Ogmios endpoint explicitly. + # (config.ts also defaults to this, but this compose is mainnet-specific.) + OGMIOS_URL: https://ogmios.vector.mainnet.apexfusion.org + volumes: + # The wallet list is read-only. The dedup state dir is writable and + # persists across runs (data/ is gitignored). + - ../../wallet-monitor/wallets.json:/repo/wallet-monitor/wallets.json:ro + - ../../wallet-monitor/data:/repo/wallet-monitor/data + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f42f63..1432f74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -290,6 +290,16 @@ importers: specifier: ^5.7.2 version: 5.9.3 + wallet-monitor: + dependencies: + '@marketplace/shared': + specifier: workspace:* + version: link:../packages/shared + devDependencies: + typescript: + specifier: ^5.7.2 + version: 5.9.3 + packages: '@adobe/css-tools@4.4.4': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c88c1e3..a1ea6a7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,4 @@ packages: - "supplier" - "indexer" - "indexer-ui" + - "wallet-monitor" diff --git a/wallet-monitor/.env.example b/wallet-monitor/.env.example new file mode 100644 index 0000000..c80710f --- /dev/null +++ b/wallet-monitor/.env.example @@ -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 diff --git a/wallet-monitor/Dockerfile b/wallet-monitor/Dockerfile new file mode 100644 index 0000000..708cfc0 --- /dev/null +++ b/wallet-monitor/Dockerfile @@ -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"] diff --git a/wallet-monitor/README.md b/wallet-monitor/README.md new file mode 100644 index 0000000..8dce549 --- /dev/null +++ b/wallet-monitor/README.md @@ -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. diff --git a/wallet-monitor/package.json b/wallet-monitor/package.json new file mode 100644 index 0000000..b1f2aaa --- /dev/null +++ b/wallet-monitor/package.json @@ -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" + } +} diff --git a/wallet-monitor/src/config.ts b/wallet-monitor/src/config.ts new file mode 100644 index 0000000..489084c --- /dev/null +++ b/wallet-monitor/src/config.ts @@ -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, 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, 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): 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 { + 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(); + 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 }; +} diff --git a/wallet-monitor/src/index.ts b/wallet-monitor/src/index.ts new file mode 100644 index 0000000..80721d6 --- /dev/null +++ b/wallet-monitor/src/index.ts @@ -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; + +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 { + 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 => { + 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); +}); diff --git a/wallet-monitor/src/slack.ts b/wallet-monitor/src/slack.ts new file mode 100644 index 0000000..d08a08b --- /dev/null +++ b/wallet-monitor/src/slack.ts @@ -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 { + 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) }; + } +} diff --git a/wallet-monitor/tsconfig.json b/wallet-monitor/tsconfig.json new file mode 100644 index 0000000..56d0e64 --- /dev/null +++ b/wallet-monitor/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/wallet-monitor/wallets.example.json b/wallet-monitor/wallets.example.json new file mode 100644 index 0000000..0c4496b --- /dev/null +++ b/wallet-monitor/wallets.example.json @@ -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 } + ] +}