/** Ops health-check for the multi-user copy daemon (Friend-beta Block F / F1).
* Sends a Telegram alert when something is wrong, so testers' money isn't silently stuck:
* - daemon stale: no new leader trade processed in > STALE_MIN minutes (state file mtime
* / lastTs), i.e. detection likely dead (RTDS 429, crash loop, no leaders).
* - low balance: an active user's deposit-wallet pUSD < LOW_USD (can't copy → dead weight).
* Read-only (no trading). Run from cron, e.g. every 15 min, next to the daemon.
*
* Env: SUBS_URL=https://poly-dev.szhub.space/api/copy/active SERVICE_TOKEN=...
* TG_BOT_TOKEN=... TG_CHAT_ID=... STATE=./copy-mu-state.json
* STALE_MIN (default 90) LOW_USD (default 5) FOLLOWER=did,did (allow-list) */
import fs from 'node:fs';
import { createPublicClient, http, erc20Abi } from 'viem';
import { polygon } from 'viem/chains';
const SUBS_URL = process.env.SUBS_URL || '';
const SERVICE_TOKEN = process.env.SERVICE_TOKEN || '';
const TG_BOT_TOKEN = process.env.TG_BOT_TOKEN || '';
const TG_CHAT_ID = process.env.TG_CHAT_ID || '';
const STATE_FILE = process.env.STATE || './copy-mu-state.json';
const STALE_MIN = Number(process.env.STALE_MIN || '90');
const LOW_USD = Number(process.env.LOW_USD || '5');
const FOLLOWERS = new Set((process.env.FOLLOWER || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean));
const PUSD = '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB';
const pub = createPublicClient({ chain: polygon, transport: http(process.env.RPC || 'https://polygon.drpc.org') });
async function tg(text) {
if (!TG_BOT_TOKEN || !TG_CHAT_ID) { console.log('[alert]', text, '(no TG creds → console only)'); return; }
try {
await fetch(`https://api.telegram.org/bot${TG_BOT_TOKEN}/sendMessage`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chat_id: TG_CHAT_ID, text, parse_mode: 'HTML', disable_web_page_preview: true }),
signal: AbortSignal.timeout(12000),
});
} catch (e) { console.log('[tg] err:', e.message); }
}
async function main() {
const alerts = [];
const nowSec = Math.floor(Date.now() / 1000);
// 1) Daemon freshness — newest per-leader lastTs in the poll state (or file mtime fallback).
try {
const raw = fs.readFileSync(STATE_FILE, 'utf8');
const st = JSON.parse(raw);
const lastTs = st.lastTs && typeof st.lastTs === 'object' ? Math.max(0, ...Object.values(st.lastTs).map(Number)) : 0;
const mtime = Math.floor(fs.statSync(STATE_FILE).mtimeMs / 1000);
const freshest = Math.max(lastTs, mtime); // mtime advances each tick even with no new trade
const ageMin = Math.round((nowSec - freshest) / 60);
if (ageMin > STALE_MIN) alerts.push(`🔴 Daemon stale: no activity for ${ageMin} min (limit ${STALE_MIN}). Detection may be dead (RTDS 429 / crash).`);
} catch (e) {
alerts.push(`🔴 Daemon state unreadable (${STATE_FILE}): ${e.message}. Daemon may not be running.`);
}
// 2) Per-user low deposit-wallet balance.
if (SUBS_URL) {
try {
const r = await fetch(SUBS_URL, { headers: { 'x-service-token': SERVICE_TOKEN }, signal: AbortSignal.timeout(12000) });
if (r.ok) {
const d = await r.json();
const seen = new Map(); // userAddress → dw
for (const s of (d.data || [])) {
const ua = String(s.userAddress || '').toLowerCase();
if (FOLLOWERS.size && !FOLLOWERS.has(ua)) continue;
if (s.depositWallet && !seen.has(ua)) seen.set(ua, String(s.depositWallet));
}
for (const [ua, dw] of seen) {
try {
const bal = Number(await pub.readContract({ address: PUSD, abi: erc20Abi, functionName: 'balanceOf', args: [dw] })) / 1e6;
if (bal < LOW_USD) alerts.push(`🟡 Low balance: ${ua.slice(0, 18)}… dw ${dw.slice(0, 10)} = $${bal.toFixed(2)} pUSD (< $${LOW_USD}) — can't copy.`);
} catch { /* RPC blip → skip this user this cycle */ }
}
} else { alerts.push(`🟡 /api/copy/active http ${r.status}`); }
} catch (e) { alerts.push(`🟡 subs check failed: ${e.message}`); }
}
if (alerts.length) { await tg(`<b>PolyCopy MU health</b>\n` + alerts.join('\n')); console.log(`[health] ${alerts.length} alert(s) sent`); }
else console.log('[health] all green');
}
main();