← Back
/** 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();